refactor(skill-state): harden stateful-skills keyword detection and state init

Replace per-skill ad-hoc detectors with a unified workflow slot ledger that
tracks all 8 canonical workflow skills (autopilot, ralph, team, ultrawork,
ultraqa, deep-interview, ralplan, self-improve) with explicit dual-copy
writes (root + session), soft-tombstone semantics, and TTL pruning.

Key changes:
- Expand canonical mode registry: deep-interview and self-improve promoted
  to full ExecutionMode with MODE_CONFIGS entries (mode-names.ts,
  mode-registry/{types,index}.ts, state-tools.ts)
- New skill-active-state v2 mixed schema with active_skills + support_skill
  branches; 7 new helpers in skill-state/index.ts including
  writeSkillActiveStateCopies() as the single persistence path
- Unified parseExplicitWorkflowSlashInvocation() handles /name, /omc:name,
  /oh-my-claudecode:name prefixes across all workflow skills; PreToolUse
  confirms, PostToolUse tombstones
- Tombstone-aware completion gating in persistent-mode with authority-first
  ordering for nested autopilot -> ralph; env kill-switches (DISABLE_OMC,
  OMC_SKIP_HOOKS, OMC_TEAM_WORKER) bypass the detector cleanly
- +107 tests across 7 files covering dual-write invariant, TTL prune,
  diverged-copy reconciliation, nested lineage, prefix-detector unity,
  intent-pattern guards, kill switches, tombstone suppression

Verified via independent codex ralplan consensus (APPROVE verdict) and
lane-isolated verifier pass. Full suite: lint clean, tsc clean, targeted
tests green (181 passed on skill-state + bridge-routing).

Constraint: writeSkillActiveStateCopies() must be the only helper that
  persists workflow-slot state; divergence between root and session copies
  forces full rewrite on next mutation
Constraint: soft-tombstone semantics (completed_at set, slot retained)
  preserve nested parent/child relationships across session boundaries
Rejected: per-skill independent state files | would reintroduce divergence
Rejected: hard-delete on completion | breaks nested workflow authority
  resolution when autopilot spawns ralph
Confidence: high
Scope-risk: moderate
Directive: every workflow-slot write/confirm/tombstone/prune/clear MUST
  route through writeSkillActiveStateCopies(); direct writes to either
  copy violate the dual-write invariant
Not-tested: cross-session race where two Claude Code sessions mutate the
  same workflow slot concurrently (single-writer assumption holds today)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex Review
2026-04-17 11:26:29 +00:00
parent e9856ebc2f
commit 56718e0502
94 changed files with 5709 additions and 530 deletions

6
dist/__tests__/auto-update.test.js generated vendored
View File

@@ -134,7 +134,7 @@ describe('auto-update reconciliation', () => {
});
it('syncs active plugin cache roots and logs when copy occurs', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
const activeRoot = '/tmp/.claude/plugins/cache/omc/oh-my-claudecode/4.1.5';
const activeRoot = join(CLAUDE_CONFIG_DIR, 'plugins', 'cache', 'omc', 'oh-my-claudecode', '4.1.5');
mockedReadFileSync.mockImplementation((path) => {
const normalized = String(path).replace(/\\/g, '/');
if (normalized.includes('.omc-version.json')) {
@@ -308,8 +308,8 @@ describe('auto-update reconciliation', () => {
});
it('dedupes plugin roots and ignores missing targets during sync', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
const activeRoot = '/tmp/.claude/plugins/cache/omc/oh-my-claudecode/4.1.5';
const staleRoot = '/tmp/.claude/plugins/cache/omc/oh-my-claudecode/4.1.4';
const activeRoot = join(CLAUDE_CONFIG_DIR, 'plugins', 'cache', 'omc', 'oh-my-claudecode', '4.1.5');
const staleRoot = join(CLAUDE_CONFIG_DIR, 'plugins', 'cache', 'omc', 'oh-my-claudecode', '4.1.4');
process.env.CLAUDE_PLUGIN_ROOT = activeRoot;
mockedReadFileSync.mockImplementation((path) => {
const normalized = String(path).replace(/\\/g, '/');

File diff suppressed because one or more lines are too long

View File

@@ -161,7 +161,22 @@ function runHook(toolInput, env) {
const result = spawnSync('node', [HOOK_PATH], {
input: stdin,
encoding: 'utf8',
env: { ...process.env, ...env, OMC_ROUTING_FORCE_INHERIT: 'true' },
env: {
...process.env,
// Reset tier-resolution chain so host env doesn't leak into tests.
OMC_SUBAGENT_MODEL: '',
OMC_MODEL_LOW: '',
OMC_MODEL_MEDIUM: '',
OMC_MODEL_HIGH: '',
CLAUDE_CODE_BEDROCK_HAIKU_MODEL: '',
CLAUDE_CODE_BEDROCK_SONNET_MODEL: '',
CLAUDE_CODE_BEDROCK_OPUS_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: '',
...env,
OMC_ROUTING_FORCE_INHERIT: 'true',
},
timeout: 10000,
});
const lines = (result.stdout || '').split('\n').filter(Boolean);
@@ -197,13 +212,21 @@ describe('hook integration — force-inherit + [1m] scenarios', () => {
expect(result.reason).toMatch(/model="sonnet"/);
expect(result.reason).toMatch(/global\.anthropic\.claude-sonnet-4-6\[1m\]/);
});
it('derives tier alias from OMC_SUBAGENT_MODEL when set', () => {
it('derives tier alias from session model when ANTHROPIC_DEFAULT_SONNET_MODEL is set', () => {
const result = runHook({}, {
ANTHROPIC_MODEL: 'global.anthropic.claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
});
expect(result.denied).toBe(true);
// normalizeToCcAlias(sessionModel) → 'sonnet'; resolvedSafe is truthy
expect(result.reason).toMatch(/model="sonnet"/);
});
it('derives tier alias from OMC_SUBAGENT_MODEL when set (backward compat)', () => {
const result = runHook({}, {
ANTHROPIC_MODEL: 'global.anthropic.claude-sonnet-4-6[1m]',
OMC_SUBAGENT_MODEL: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
});
expect(result.denied).toBe(true);
// normalizeToCcAlias('us.anthropic.claude-sonnet-4-5-...') → 'sonnet'
expect(result.reason).toMatch(/model="sonnet"/);
});
it('denies no-model call when only ANTHROPIC_MODEL has [1m] suffix (any [1m] triggers deny)', () => {
@@ -223,13 +246,13 @@ describe('hook integration — force-inherit + [1m] scenarios', () => {
expect(result.reason).toMatch(/model="sonnet"/);
expect(result.reason).toMatch(/claude-sonnet-4-6\[1m\]/);
});
it('derives tier alias from OMC_SUBAGENT_MODEL for guidance in [1m] deny', () => {
// normalizeToCcAlias('us.anthropic.claude-sonnet-4-5-20250929-v1:0') → 'sonnet'
it('derives tier alias from ANTHROPIC_DEFAULT_SONNET_MODEL for guidance in [1m] deny', () => {
const result = runHook({}, {
ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]',
OMC_SUBAGENT_MODEL: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
});
expect(result.denied).toBe(true);
// normalizeToCcAlias('claude-sonnet-4-6[1m]') → 'sonnet'; resolvedSafe is truthy
expect(result.reason).toMatch(/model="sonnet"/);
});
it('denies no-model call when CLAUDE_MODEL is provider-specific[1m] but ANTHROPIC_MODEL is bare[1m]', () => {

File diff suppressed because one or more lines are too long

View File

@@ -116,7 +116,7 @@ describe('Bedrock model routing repro', () => {
const defs = getAgentDefinitions({ config });
expect(defs['executor'].model).toBe('claude-sonnet-4-6');
expect(defs['explore'].model).toBe('claude-haiku-4-5');
expect(defs['architect'].model).toBe('claude-opus-4-6');
expect(defs['architect'].model).toBe('claude-opus-4-7');
// 4. enforceModel normalizes to bare CC-supported aliases (FIX)
const { enforceModel } = await import('../features/delegation-enforcer.js');
// 4a. executor → 'sonnet' (normalized from config's full model ID)

18
dist/__tests__/hud/model.test.js generated vendored
View File

@@ -3,7 +3,7 @@ import { formatModelName, renderModel } from '../../hud/elements/model.js';
describe('model element', () => {
describe('formatModelName', () => {
it('returns Opus for opus model IDs', () => {
expect(formatModelName('claude-opus-4-6-20260205')).toBe('Opus');
expect(formatModelName('claude-opus-4-7-20260416')).toBe('Opus');
expect(formatModelName('claude-3-opus-20240229')).toBe('Opus');
});
it('returns Sonnet for sonnet model IDs', () => {
@@ -18,20 +18,20 @@ describe('model element', () => {
expect(formatModelName(undefined)).toBeNull();
});
it('returns versioned name from model IDs', () => {
expect(formatModelName('claude-opus-4-6-20260205', 'versioned')).toBe('Opus 4.6');
expect(formatModelName('claude-opus-4-7-20260416', 'versioned')).toBe('Opus 4.7');
expect(formatModelName('claude-sonnet-4-6-20260217', 'versioned')).toBe('Sonnet 4.6');
expect(formatModelName('claude-haiku-4-5-20251001', 'versioned')).toBe('Haiku 4.5');
});
it('returns versioned name from display names', () => {
expect(formatModelName('Sonnet 4.5', 'versioned')).toBe('Sonnet 4.5');
expect(formatModelName('Opus 4.6', 'versioned')).toBe('Opus 4.6');
expect(formatModelName('Opus 4.7', 'versioned')).toBe('Opus 4.7');
expect(formatModelName('Haiku 4.5', 'versioned')).toBe('Haiku 4.5');
});
it('falls back to short name when no version found', () => {
expect(formatModelName('claude-3-opus-20240229', 'versioned')).toBe('Opus');
});
it('returns full model ID in full format', () => {
expect(formatModelName('claude-opus-4-6-20260205', 'full')).toBe('claude-opus-4-6-20260205');
expect(formatModelName('claude-opus-4-7-20260416', 'full')).toBe('claude-opus-4-7-20260416');
});
it('truncates long unrecognized model names', () => {
const longName = 'some-very-long-model-name-that-exceeds-limit';
@@ -40,20 +40,20 @@ describe('model element', () => {
});
describe('renderModel', () => {
it('renders formatted model name', () => {
const result = renderModel('claude-opus-4-6-20260205');
const result = renderModel('claude-opus-4-7-20260416');
expect(result).not.toBeNull();
expect(result).toContain('Opus');
});
it('renders versioned format', () => {
const result = renderModel('claude-opus-4-6-20260205', 'versioned');
const result = renderModel('claude-opus-4-7-20260416', 'versioned');
expect(result).not.toBeNull();
expect(result).toContain('Opus');
expect(result).toContain('4.6');
expect(result).toContain('4.7');
});
it('renders full format', () => {
const result = renderModel('claude-opus-4-6-20260205', 'full');
const result = renderModel('claude-opus-4-7-20260416', 'full');
expect(result).not.toBeNull();
expect(result).toContain('claude-opus-4-6');
expect(result).toContain('claude-opus-4-7');
});
it('returns null for null input', () => {
expect(renderModel(null)).toBeNull();

2
dist/__tests__/npm-package-hook-surface.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=npm-package-hook-surface.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"npm-package-hook-surface.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/npm-package-hook-surface.test.ts"],"names":[],"mappings":""}

85
dist/__tests__/npm-package-hook-surface.test.js generated vendored Normal file
View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, normalize, relative } from 'node:path';
const PACKAGE_ROOT = process.cwd();
const HOOKS_JSON_PATH = join(PACKAGE_ROOT, 'hooks', 'hooks.json');
const SCRIPTS_ROOT = join(PACKAGE_ROOT, 'scripts');
const LOCAL_IMPORT_RE = /(?:import\s+(?:[^'"()]+?\s+from\s+)?|import\s*\(|export\s+\*\s+from\s+|export\s+\{[^}]*\}\s+from\s+|require\s*\()\s*['"](\.[^'"]+)['"]/g;
const PLUGIN_SCRIPT_RE = /"\$CLAUDE_PLUGIN_ROOT"\/(scripts\/[^\s"]+)/g;
function listHookScriptEntries() {
const hooksJson = JSON.parse(readFileSync(HOOKS_JSON_PATH, 'utf-8'));
const entries = new Set(['scripts/run.cjs']);
for (const eventHooks of Object.values(hooksJson.hooks ?? {})) {
for (const matcherEntry of eventHooks) {
for (const hook of matcherEntry.hooks ?? []) {
const command = hook.command ?? '';
for (const match of command.matchAll(PLUGIN_SCRIPT_RE)) {
entries.add(match[1]);
}
}
}
}
return [...entries].sort();
}
function resolveRelativeScriptImport(fromFile, specifier) {
const resolved = normalize(join(dirname(fromFile), specifier));
const candidates = [
resolved,
`${resolved}.mjs`,
`${resolved}.cjs`,
`${resolved}.js`,
join(resolved, 'index.mjs'),
join(resolved, 'index.cjs'),
join(resolved, 'index.js'),
];
for (const candidate of candidates) {
if (candidate.startsWith(SCRIPTS_ROOT) && existsSync(candidate)) {
return candidate;
}
}
return null;
}
function collectRequiredScriptFiles(entryRelPath, collected = new Set()) {
const absolutePath = join(PACKAGE_ROOT, entryRelPath);
if (!existsSync(absolutePath)) {
throw new Error(`Required hook file is missing in repo: ${entryRelPath}`);
}
const normalizedRel = relative(PACKAGE_ROOT, absolutePath).replace(/\\/g, '/');
if (collected.has(normalizedRel)) {
return collected;
}
collected.add(normalizedRel);
const content = readFileSync(absolutePath, 'utf-8');
for (const match of content.matchAll(LOCAL_IMPORT_RE)) {
const resolved = resolveRelativeScriptImport(absolutePath, match[1]);
if (!resolved) {
continue;
}
collectRequiredScriptFiles(relative(PACKAGE_ROOT, resolved).replace(/\\/g, '/'), collected);
}
return collected;
}
function getPackedFiles() {
const stdout = execFileSync('npm', ['pack', '--dry-run', '--json'], {
cwd: PACKAGE_ROOT,
encoding: 'utf-8',
});
const results = JSON.parse(stdout);
return new Set((results[0]?.files ?? []).map(file => file.path));
}
describe('npm package hook surface regression', () => {
it('packs hooks.json, hook entry scripts, and their local script dependencies', () => {
const requiredFiles = new Set(['hooks/hooks.json']);
for (const entryRelPath of listHookScriptEntries()) {
for (const file of collectRequiredScriptFiles(entryRelPath)) {
requiredFiles.add(file);
}
}
const packedFiles = getPackedFiles();
expect([...requiredFiles].sort()).not.toHaveLength(0);
const missing = [...requiredFiles].filter(file => !packedFiles.has(file)).sort();
expect(missing).toEqual([]);
});
});
//# sourceMappingURL=npm-package-hook-surface.test.js.map

1
dist/__tests__/npm-package-hook-surface.test.js.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"npm-package-hook-surface.test.js","sourceRoot":"","sources":["../../src/__tests__/npm-package-hook-surface.test.ts"],"names":[],"mappings":"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"}

8
dist/__tests__/types.test.js generated vendored
View File

@@ -47,13 +47,13 @@ describe('Type Tests', () => {
const config = {
agents: {
omc: { model: 'claude-sonnet-4-6' },
architect: { model: 'claude-opus-4-6' },
architect: { model: 'claude-opus-4-7' },
explore: { model: 'claude-haiku-4-5' },
documentSpecialist: { model: 'claude-haiku-4-5' },
},
};
expect(config.agents?.omc?.model).toBe('claude-sonnet-4-6');
expect(config.agents?.architect?.model).toBe('claude-opus-4-6');
expect(config.agents?.architect?.model).toBe('claude-opus-4-7');
});
it('should support routing configuration', () => {
const config = {
@@ -65,13 +65,13 @@ describe('Type Tests', () => {
tierModels: {
LOW: 'claude-haiku-4',
MEDIUM: 'claude-sonnet-4-6',
HIGH: 'claude-opus-4-6',
HIGH: 'claude-opus-4-7',
},
},
};
expect(config.routing?.enabled).toBe(true);
expect(config.routing?.defaultTier).toBe('MEDIUM');
expect(config.routing?.tierModels?.HIGH).toBe('claude-opus-4-6');
expect(config.routing?.tierModels?.HIGH).toBe('claude-opus-4-7');
});
});
});

2
dist/features/auto-update.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"auto-update.d.ts","sourceRoot":"","sources":["../../src/features/auto-update.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"auto-update.d.ts","sourceRoot":"","sources":["../../src/features/auto-update.ts"],"names":[],"mappings":"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"}

54
dist/features/auto-update.js generated vendored
View File

@@ -9,10 +9,10 @@
* - Store version metadata for installed components
* - Configurable update notifications
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync } from 'fs';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { execSync, execFileSync } from 'child_process';
import { install as installOmc, HOOKS_DIR, isProjectScopedPlugin, isRunningAsPlugin, getInstalledOmcPluginRoots, getRuntimePackageRoot, } from '../installer/index.js';
import { install as installOmc, HOOKS_DIR, isProjectScopedPlugin, isRunningAsPlugin, copyPluginSyncPayload, syncInstalledPluginPayload, } from '../installer/index.js';
import { getClaudeConfigDir } from '../utils/config-dir.js';
import { purgeStalePluginCacheVersions } from '../utils/paths.js';
import { isAutoUpdateDisabled } from '../lib/security-config.js';
@@ -102,56 +102,8 @@ function syncMarketplaceClone(verbose = false) {
}
return { ok: true, message: 'Marketplace clone updated' };
}
const PLUGIN_SYNC_PAYLOAD = [
'dist',
'bridge',
'hooks',
'scripts',
'skills',
'agents',
'templates',
'docs',
'.claude-plugin',
'.mcp.json',
'README.md',
'LICENSE',
'package.json',
];
function copyPluginSyncPayload(sourceRoot, targetRoots) {
if (targetRoots.length === 0) {
return { synced: false, errors: [] };
}
let synced = false;
const errors = [];
for (const targetRoot of targetRoots) {
let copiedToTarget = false;
for (const entry of PLUGIN_SYNC_PAYLOAD) {
const sourcePath = join(sourceRoot, entry);
if (!existsSync(sourcePath)) {
continue;
}
try {
cpSync(sourcePath, join(targetRoot, entry), {
recursive: true,
force: true,
});
copiedToTarget = true;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync ${entry} to ${targetRoot}: ${message}`);
}
}
synced = synced || copiedToTarget;
}
return { synced, errors };
}
function syncActivePluginCache() {
const activeRoots = getInstalledOmcPluginRoots().filter(root => existsSync(root));
if (activeRoots.length === 0) {
return { synced: false, errors: [] };
}
const result = copyPluginSyncPayload(getRuntimePackageRoot(), activeRoots);
const result = syncInstalledPluginPayload();
if (result.synced) {
console.log('[omc update] Synced plugin cache');
}

2
dist/features/auto-update.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -704,6 +704,96 @@ $ ultrawork search the codebase`,
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot for explicit /deep-interview slash invocation in UserPromptSubmit', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-di-slash-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'di-slash-session';
const result = await processHook('keyword-detector', {
sessionId,
prompt: '/oh-my-claudecode:deep-interview explore auth flows',
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8'));
expect(slot.version).toBe(2);
expect(slot.active_skills?.['deep-interview']?.initialized_mode).toBe('deep-interview');
expect(slot.active_skills?.['deep-interview']?.session_id).toBe(sessionId);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot for explicit /self-improve slash invocation in UserPromptSubmit', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-si-slash-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'si-slash-session';
const result = await processHook('keyword-detector', {
sessionId,
prompt: '/self-improve refactor test coverage',
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8'));
expect(slot.version).toBe(2);
expect(slot.active_skills?.['self-improve']?.initialized_mode).toBe('self-improve');
expect(slot.active_skills?.['self-improve']?.session_id).toBe(sessionId);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot when Skill tool invokes oh-my-claudecode:deep-interview', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-di-skill-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'di-skill-session';
const result = await processHook('pre-tool-use', {
sessionId,
toolName: 'Skill',
toolInput: { skill: 'oh-my-claudecode:deep-interview' },
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8'));
expect(slot.version).toBe(2);
expect(slot.active_skills?.['deep-interview']?.initialized_mode).toBe('deep-interview');
expect(slot.active_skills?.['deep-interview']?.session_id).toBe(sessionId);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot when Skill tool invokes oh-my-claudecode:self-improve', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-si-skill-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'si-skill-session';
const result = await processHook('pre-tool-use', {
sessionId,
toolName: 'Skill',
toolInput: { skill: 'oh-my-claudecode:self-improve' },
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8'));
expect(slot.version).toBe(2);
expect(slot.active_skills?.['self-improve']?.initialized_mode).toBe('self-improve');
expect(slot.active_skills?.['self-improve']?.session_id).toBe(sessionId);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('should handle session-start and return continue:true', async () => {
const input = {
sessionId: 'test-session',

File diff suppressed because one or more lines are too long

2
dist/hooks/bridge.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/hooks/bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA2pBH;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAc9D;AA0BD;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,yBAAyB;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uBAAuB;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,4CAA4C;IAC5C,KAAK,CAAC,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;IACH,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wBAAwB;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,QAAQ,EAAE,OAAO,CAAC;IAClB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,KAAK,sBAAsB,GAAG,UAAU,GAAG;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9C,CAAC;AAMF;;;;;;;GAOG;AACH,wBAAgB,kCAAkC,CAChD,MAAM,EAAE,sBAAsB,GAC7B,sBAAsB,CA8BxB;AAOD;;GAEG;AACH,MAAM,MAAM,QAAQ,GAChB,kBAAkB,GAClB,mBAAmB,GACnB,OAAO,GACP,iBAAiB,GACjB,eAAe,GACf,aAAa,GACb,cAAc,GACd,eAAe,GACf,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,aAAa,GACb,YAAY,GACZ,mBAAmB,GACnB,oBAAoB,GACpB,iBAAiB,CAAC;AAiwBtB;;;;GAIG;AACH,wBAAgB,mCAAmC,CACjD,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,GACjB,IAAI,CAyBN;AAED,sEAAsE;AACtE,eAAO,MAAM,OAAO;;CAEnB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS;kBAEX,OAAO,sBAAsB,EAAE,iBAAiB,WAC9C,OAAO,sBAAsB,EAAE,eAAe;CAU1D,CAAC;AAknBF;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,SAAS,GAClB,OAAO,CAAC,UAAU,CAAC,CAgPrB;AAED;;;GAGG;AACH,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAoC1C"}
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/hooks/bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAqqBH;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAc9D;AA0BD;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,yBAAyB;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uBAAuB;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,4CAA4C;IAC5C,KAAK,CAAC,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;IACH,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wBAAwB;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,QAAQ,EAAE,OAAO,CAAC;IAClB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,KAAK,sBAAsB,GAAG,UAAU,GAAG;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9C,CAAC;AAMF;;;;;;;GAOG;AACH,wBAAgB,kCAAkC,CAChD,MAAM,EAAE,sBAAsB,GAC7B,sBAAsB,CA8BxB;AAOD;;GAEG;AACH,MAAM,MAAM,QAAQ,GAChB,kBAAkB,GAClB,mBAAmB,GACnB,OAAO,GACP,iBAAiB,GACjB,eAAe,GACf,aAAa,GACb,cAAc,GACd,eAAe,GACf,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,aAAa,GACb,YAAY,GACZ,mBAAmB,GACnB,oBAAoB,GACpB,iBAAiB,CAAC;AA87BtB;;;;GAIG;AACH,wBAAgB,mCAAmC,CACjD,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,GACjB,IAAI,CAyBN;AAED,sEAAsE;AACtE,eAAO,MAAM,OAAO;;CAEnB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS;kBAEX,OAAO,sBAAsB,EAAE,iBAAiB,WAC9C,OAAO,sBAAsB,EAAE,eAAe;CAU1D,CAAC;AAwoBF;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,SAAS,GAClB,OAAO,CAAC,UAAU,CAAC,CAgPrB;AAED;;;GAGG;AACH,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAoC1C"}

191
dist/hooks/bridge.js generated vendored
View File

@@ -30,7 +30,8 @@ import { compactOmcStartupGuidance, loadConfig } from "../config/loader.js";
import { activatePromptPrerequisiteState, buildPromptPrerequisiteDenyReason, buildPromptPrerequisiteReminder, clearPromptPrerequisiteState, getPromptPrerequisiteConfig, isPromptPrerequisiteBlockingTool, parsePromptPrerequisiteSections, readPromptPrerequisiteState, recordPromptPrerequisiteProgress, shouldEnforcePromptPrerequisites, } from "./prompt-prerequisites/index.js";
import { resolveAutopilotPlanPath, resolveOpenQuestionsPlanPath, } from "../config/plan-output.js";
import { formatAutopilotRuntimeInsight } from "./autopilot/runtime-insight.js";
import { writeSkillActiveState } from "./skill-state/index.js";
import { writeSkillActiveState, isCanonicalWorkflowSkill, upsertWorkflowSkillSlot, markWorkflowSkillCompleted, pruneExpiredWorkflowSkillTombstones, readSkillActiveStateNormalized, writeSkillActiveStateCopies, } from "./skill-state/index.js";
import { parseExplicitWorkflowSlashInvocation } from "./keyword-detector/index.js";
import { ULTRAWORK_MESSAGE, ULTRATHINK_MESSAGE, SEARCH_MESSAGE, ANALYZE_MESSAGE, TDD_MESSAGE, CODE_REVIEW_MESSAGE, SECURITY_REVIEW_MESSAGE, RALPH_MESSAGE, PROMPT_TRANSLATION_MESSAGE, } from "../installer/hooks.js";
// Agent dashboard is used in pre/post-tool-use hot path
import { getAgentDashboard } from "./subagent-tracker/index.js";
@@ -571,9 +572,6 @@ function getPromptText(input) {
}
return "";
}
function isExplicitRalplanSlashInvocation(promptText) {
return /^\s*\/(?:oh-my-claudecode:)?ralplan(?:\s|$)/i.test(promptText);
}
function isExplicitAskSlashInvocation(promptText) {
return /^\s*\/(?:oh-my-claudecode:)?ask\s+(?:claude|codex|gemini)\b/i.test(promptText);
}
@@ -589,6 +587,136 @@ function activateRalplanStartupState(directory, sessionId) {
last_checked_at: now,
}, directory, sessionId);
}
/**
* Resolve the on-disk path of the mode-specific state file for a workflow
* skill. Returns the session-scoped path when a session id is available, else
* the root path. Used to persist `mode_state_path` on the workflow slot so
* downstream consumers can locate the mode payload.
*/
function resolveWorkflowSlotModeStatePath(directory, skillName, sessionId) {
const paths = getModeStatePaths(directory, skillName, sessionId);
return paths[0] ?? "";
}
/**
* Seed (or refresh) a canonical workflow-slot entry in the dual-copy ledger
* via the only sanctioned helper, `writeSkillActiveStateCopies()`. Returns
* `true` when at least one copy was written, `false` on best-effort failure.
*/
function seedWorkflowSlotForSkill(directory, skillName, sessionId, source, parentSkill) {
if (!isCanonicalWorkflowSkill(skillName))
return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
const pruned = pruneExpiredWorkflowSkillTombstones(current);
// Resolve mode-state file pointers eagerly so downstream readers can
// locate the mode payload without re-deriving the path.
const rootStatePath = resolveStatePathSafe("skill-active", directory);
const sessionStatePath = sessionId
? resolveSessionStatePathSafe("skill-active", sessionId, directory)
: "";
const modeStatePath = resolveWorkflowSlotModeStatePath(directory, normalized, sessionId);
const slotData = {
session_id: sessionId ?? "",
mode_state_path: modeStatePath,
initialized_mode: normalized,
initialized_state_path: rootStatePath,
initialized_session_state_path: sessionStatePath,
source,
};
if (parentSkill !== undefined) {
slotData.parent_skill = parentSkill;
}
const next = upsertWorkflowSkillSlot(pruned, normalized, slotData);
return writeSkillActiveStateCopies(directory, next, sessionId);
}
catch {
return false;
}
}
/**
* Idempotently confirm a workflow slot — refreshes `last_confirmed_at` when
* the slot is live. No-op when the slot is missing or already tombstoned.
*/
function confirmWorkflowSlot(directory, skillName, sessionId) {
if (!isCanonicalWorkflowSkill(skillName))
return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
const slot = current.active_skills[normalized];
if (!slot || slot.completed_at)
return false;
const next = upsertWorkflowSkillSlot(current, normalized, {
last_confirmed_at: new Date().toISOString(),
});
return writeSkillActiveStateCopies(directory, next, sessionId);
}
catch {
return false;
}
}
/**
* Soft-tombstone a workflow slot on completion. The slot is retained until
* the TTL pruner removes it, so late-arriving stop hooks see consistent
* state.
*/
function tombstoneWorkflowSlot(directory, skillName, sessionId) {
if (!isCanonicalWorkflowSkill(skillName))
return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
if (!current.active_skills[normalized])
return false;
const next = markWorkflowSkillCompleted(current, normalized);
return writeSkillActiveStateCopies(directory, next, sessionId);
}
catch {
return false;
}
}
function resolveStatePathSafe(stateName, directory) {
try {
// Lazy resolve to avoid a circular import; same module is imported in
// skill-state via the mode-paths registry.
return join(getOmcRoot(directory), "state", `${stateName}-state.json`);
}
catch {
return "";
}
}
function resolveSessionStatePathSafe(stateName, sessionId, directory) {
try {
return join(getOmcRoot(directory), "state", "sessions", sessionId, `${stateName}-state.json`);
}
catch {
return "";
}
}
/**
* Mode-specific seeding entrypoints invoked alongside the workflow slot when
* the user issues an explicit slash command. Each branch is a no-op when the
* mode does not require pre-skill state (e.g. `team`, where the team skill
* itself owns initial state via worker spawning).
*/
async function seedModeStateForExplicitWorkflowSlash(skill, directory, promptText, sessionId) {
switch (skill) {
case "ralplan":
activateRalplanStartupState(directory, sessionId);
return;
case "autopilot":
await seedAutopilotStartupState(directory, promptText, sessionId);
return;
default:
// ralph / ultrawork / team / ultraqa / deep-interview / self-improve
// own their state activation inside their own Skill PostToolUse handlers.
// Pre-Skill seeding for these would clobber existing in-flight state
// (e.g. nested `autopilot → ralph`); the workflow slot alone is enough
// to keep stop-hook enforcement from premature termination.
return;
}
}
/**
* Process keyword detection hook
* Detects magic keywords and returns injection message
@@ -615,18 +743,32 @@ async function processKeywordDetector(input) {
const sessionId = input.sessionId;
const directory = resolveToWorktreeRoot(input.directory);
const messages = [];
const explicitRalplanSlashInvocation = isExplicitRalplanSlashInvocation(promptText);
if (explicitRalplanSlashInvocation) {
activateRalplanStartupState(directory, sessionId);
return {
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: `[RALPLAN INIT] Explicit /ralplan invoke detected during UserPromptSubmit.\n` +
`ralplan state is armed for startup and marked awaiting confirmation, so the stop hook will not block this initialization path.\n` +
`Proceed immediately with the consensus planning workflow for:\n${promptText}`,
},
};
// Unified explicit slash invocation handler — covers all 8 canonical
// workflow skills (autopilot, ralph, team, ultrawork, ultraqa,
// deep-interview, ralplan, self-improve). Seeds the workflow slot via the
// sanctioned dual-copy helper BEFORE the Skill tool fires, and seeds the
// mode-specific state file when the mode requires pre-Skill state. The
// ralplan path additionally returns the legacy [RALPLAN INIT] context
// injection so existing routing tests remain green.
const explicitSlash = parseExplicitWorkflowSlashInvocation(promptText);
if (explicitSlash) {
seedWorkflowSlotForSkill(directory, explicitSlash.skill, sessionId, "prompt-submit:explicit-slash");
await seedModeStateForExplicitWorkflowSlash(explicitSlash.skill, directory, promptText, sessionId);
if (explicitSlash.skill === "ralplan") {
return {
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: `[RALPLAN INIT] Explicit /ralplan invoke detected during UserPromptSubmit.\n` +
`ralplan state is armed for startup and marked awaiting confirmation, so the stop hook will not block this initialization path.\n` +
`Proceed immediately with the consensus planning workflow for:\n${promptText}`,
},
};
}
// For non-ralplan workflow slash invocations, fall through so the regular
// keyword pipeline still emits the mode message constants and routes
// through the normal activation path. The workflow slot is already armed
// so the stop-hook will treat the upcoming Skill invocation as authorized.
}
// Record prompt submission time in HUD state
try {
@@ -1388,6 +1530,16 @@ function processPreToolUse(input) {
if (isConsensusPlanningSkillInvocation(skillName, input.toolInput)) {
activateRalplanState(directory, input.sessionId);
}
// Workflow-slot ledger: when the Skill tool is invoked for one of the
// 8 canonical workflow skills, ensure the slot is present and freshly
// confirmed. Seed first (idempotent — preserves existing fields when
// the slot was already armed during UserPromptSubmit), then refresh
// `last_confirmed_at` so stop-hook reconciliation can distinguish a
// truly idle workflow from an in-flight one.
if (isCanonicalWorkflowSkill(skillName)) {
seedWorkflowSlotForSkill(directory, skillName, input.sessionId, "pre-tool:skill");
confirmWorkflowSlot(directory, skillName, input.sessionId);
}
}
catch {
// Skill-state/state-sync writes are best-effort; don't fail the hook on error.
@@ -1565,6 +1717,13 @@ async function processPostToolUse(input) {
if (!currentState || !currentState.active || currentState.skill_name === completingSkill) {
clearSkillActiveState(directory, input.sessionId);
}
// Workflow-slot ledger: tombstone the canonical workflow slot when its
// Skill invocation completes. Soft-tombstoning (rather than hard delete)
// preserves the slot until the TTL pruner removes it — late-arriving
// stop hooks see consistent state instead of a missing slot.
if (skillName && isCanonicalWorkflowSkill(skillName)) {
tombstoneWorkflowSlot(directory, skillName, input.sessionId);
}
if (isConsensusPlanningSkillInvocation(skillName, input.toolInput)) {
deactivateRalplanState(directory, input.sessionId);
}

2
dist/hooks/bridge.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { removeCodeBlocks, sanitizeForKeywordDetection, extractPromptText, detectKeywordsWithType, hasKeyword, getPrimaryKeyword, getAllKeywords, getAllKeywordsWithSizeCheck, isUnderspecifiedForExecution, applyRalplanGate, NON_LATIN_SCRIPT_PATTERN, } from '../index.js';
import { removeCodeBlocks, sanitizeForKeywordDetection, extractPromptText, detectKeywordsWithType, hasKeyword, getPrimaryKeyword, getAllKeywords, getAllKeywordsWithSizeCheck, isUnderspecifiedForExecution, applyRalplanGate, NON_LATIN_SCRIPT_PATTERN, parseExplicitWorkflowSlashInvocation, } from '../index.js';
// Mock isTeamEnabled
vi.mock('../../../features/auto-update.js', () => ({
isTeamEnabled: vi.fn(() => true),
@@ -1694,5 +1694,157 @@ This article argues that fake popularity signals damage trust in open source.`;
});
});
});
// -------------------------------------------------------------------------
// Intent-pattern guards (spec h) — file paths, code fences, and backticks
// must NOT trigger keyword detection
// -------------------------------------------------------------------------
describe('intent-pattern guards: file paths and code blocks (spec h)', () => {
it('file path /ralph-logs/foo.txt does NOT detect ralph', () => {
const result = detectKeywordsWithType('/ralph-logs/foo.txt');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('path segment /path/to/ralph-config.json does NOT detect ralph', () => {
const result = detectKeywordsWithType('check /path/to/ralph-config.json for settings');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('fenced code block containing /ralph does NOT detect ralph', () => {
const result = detectKeywordsWithType('```\n/ralph fix the bug\n```');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('inline backtick `/ralph` does NOT detect ralph', () => {
const result = detectKeywordsWithType('use `/ralph` to start the loop');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('inline backtick `/oh-my-claudecode:ralph` does NOT detect ralph', () => {
const result = detectKeywordsWithType('run `/oh-my-claudecode:ralph` if needed');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('file path /autopilot-runs/log.txt does NOT detect autopilot', () => {
const result = detectKeywordsWithType('/autopilot-runs/log.txt');
expect(result.find((r) => r.type === 'autopilot')).toBeUndefined();
});
it('fenced code block containing /ultrawork does NOT detect ultrawork', () => {
const result = detectKeywordsWithType('```bash\n/ultrawork search codebase\n```');
expect(result.find((r) => r.type === 'ultrawork')).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Unified prefix detector (spec g) — /skill, /omc:skill, /oh-my-claudecode:skill
// all seed the same canonical state (T3 implementation required)
// -------------------------------------------------------------------------
describe('unified prefix detector: /omc: and /oh-my-claudecode: forms (spec g)', () => {
it('/omc:ralph fix auth detects ralph', () => {
const result = detectKeywordsWithType('/omc:ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/oh-my-claudecode:ralph fix auth detects ralph', () => {
const result = detectKeywordsWithType('/oh-my-claudecode:ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/omc:autopilot implement feature detects autopilot', () => {
const result = detectKeywordsWithType('/omc:autopilot implement feature');
expect(result.find((r) => r.type === 'autopilot')).toBeDefined();
});
it('/omc:ultrawork search codebase detects ultrawork', () => {
const result = detectKeywordsWithType('/omc:ultrawork search codebase');
expect(result.find((r) => r.type === 'ultrawork')).toBeDefined();
});
it('/ralph fix auth at message start detects ralph (explicit slash command)', () => {
const result = detectKeywordsWithType('/ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/autopilot at message start detects autopilot', () => {
const result = detectKeywordsWithType('/autopilot ship the new feature end to end');
expect(result.find((r) => r.type === 'autopilot')).toBeDefined();
});
it('/ultrawork at message start detects ultrawork', () => {
const result = detectKeywordsWithType('/ultrawork investigate this report');
expect(result.find((r) => r.type === 'ultrawork')).toBeDefined();
});
it('/deep-interview at message start detects deep-interview', () => {
const result = detectKeywordsWithType('/deep-interview about the architecture');
expect(result.find((r) => r.type === 'deep-interview')).toBeDefined();
});
it('/ralplan at message start detects ralplan', () => {
const result = detectKeywordsWithType('/ralplan issue #2622');
expect(result.find((r) => r.type === 'ralplan')).toBeDefined();
});
it('explicit slash detection does not duplicate the same keyword type', () => {
const result = detectKeywordsWithType('/ralph fix auth');
const ralphMatches = result.filter((r) => r.type === 'ralph');
expect(ralphMatches.length).toBe(1);
});
});
// -------------------------------------------------------------------------
// parseExplicitWorkflowSlashInvocation — unit tests (spec g)
// -------------------------------------------------------------------------
describe('parseExplicitWorkflowSlashInvocation — parser unit tests (spec g)', () => {
it('returns null for empty string', () => {
expect(parseExplicitWorkflowSlashInvocation('')).toBeNull();
});
it('returns null for non-slash prompt', () => {
expect(parseExplicitWorkflowSlashInvocation('ralph fix auth')).toBeNull();
});
it('parses bare /ralph with args', () => {
const result = parseExplicitWorkflowSlashInvocation('/ralph fix the auth flow');
expect(result).not.toBeNull();
expect(result.skill).toBe('ralph');
expect(result.args).toBe('fix the auth flow');
});
it('parses /omc:ralph and normalizes skill name', () => {
const result = parseExplicitWorkflowSlashInvocation('/omc:ralph debug this');
expect(result).not.toBeNull();
expect(result.skill).toBe('ralph');
});
it('parses /oh-my-claudecode:ralph and normalizes skill name', () => {
const result = parseExplicitWorkflowSlashInvocation('/oh-my-claudecode:ralph debug this');
expect(result).not.toBeNull();
expect(result.skill).toBe('ralph');
});
it('parses /autopilot with args', () => {
const result = parseExplicitWorkflowSlashInvocation('/autopilot ship the feature');
expect(result.skill).toBe('autopilot');
expect(result.args).toBe('ship the feature');
});
it('parses /deep-interview at message start', () => {
const result = parseExplicitWorkflowSlashInvocation('/deep-interview about system design');
expect(result.skill).toBe('deep-interview');
});
it('parses /self-improve at message start', () => {
const result = parseExplicitWorkflowSlashInvocation('/self-improve');
expect(result.skill).toBe('self-improve');
expect(result.args).toBe('');
});
it('returns null for /ralph-logs/foo.txt (path lookahead prevents match)', () => {
expect(parseExplicitWorkflowSlashInvocation('/ralph-logs/foo.txt')).toBeNull();
});
it('returns null for /ralph inside fenced code block', () => {
expect(parseExplicitWorkflowSlashInvocation('```\n/ralph fix this\n```')).toBeNull();
});
it('returns null for /ralph inside inline backtick', () => {
expect(parseExplicitWorkflowSlashInvocation('use `/ralph` to start')).toBeNull();
});
it('is case-insensitive: /RALPH is detected', () => {
const result = parseExplicitWorkflowSlashInvocation('/RALPH fix auth');
expect(result.skill).toBe('ralph');
});
it('leading whitespace before / is allowed', () => {
const result = parseExplicitWorkflowSlashInvocation(' /ralph fix auth');
expect(result.skill).toBe('ralph');
});
it('/ralph with no args returns empty args string', () => {
const result = parseExplicitWorkflowSlashInvocation('/ralph');
expect(result.skill).toBe('ralph');
expect(result.args).toBe('');
});
it('all three prefix forms produce the same skill name for autopilot', () => {
const bare = parseExplicitWorkflowSlashInvocation('/autopilot go');
const omc = parseExplicitWorkflowSlashInvocation('/omc:autopilot go');
const full = parseExplicitWorkflowSlashInvocation('/oh-my-claudecode:autopilot go');
expect(bare.skill).toBe('autopilot');
expect(omc.skill).toBe('autopilot');
expect(full.skill).toBe('autopilot');
});
});
});
//# sourceMappingURL=index.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,34 @@ export interface DetectedKeyword {
keyword: string;
position: number;
}
/**
* Canonical workflow skills detected via explicit slash invocation.
* Mirrors `CANONICAL_WORKFLOW_SKILLS` in `skill-state/index.ts`. Listed here
* (rather than imported) to keep the keyword-detector free of cross-module
* dependencies on skill-state.
*/
declare const CANONICAL_WORKFLOW_SLASH_SKILLS: readonly ["autopilot", "ralph", "team", "ultrawork", "ultraqa", "deep-interview", "ralplan", "self-improve"];
export type CanonicalWorkflowSlashSkill = (typeof CANONICAL_WORKFLOW_SLASH_SKILLS)[number];
export interface ExplicitWorkflowSlashInvocation {
/** Canonical workflow skill name (lowercase, no `oh-my-claudecode:` prefix). */
skill: CanonicalWorkflowSlashSkill;
/** Trailing arguments after the slash command. */
args: string;
/** Raw matched prefix (including any namespace prefix and the skill name). */
raw: string;
}
/**
* Parse an explicit workflow slash invocation at the start of a prompt.
*
* Recognizes `/<skill>`, `/omc:<skill>`, and `/oh-my-claudecode:<skill>` for
* the canonical workflow skill list. Code fences and inline backticks are
* stripped first so quoted commands do not match. The trailing lookahead
* (whitespace, end-of-text, or punctuation) prevents file paths like
* `/ralph-logs/foo.txt` from matching `/ralph`.
*
* Returns `null` when no explicit invocation is present.
*/
export declare function parseExplicitWorkflowSlashInvocation(promptText: string): ExplicitWorkflowSlashInvocation | null;
/**
* Remove code blocks from text to prevent false positives
* Handles both fenced code blocks and inline code
@@ -105,4 +133,5 @@ export declare function applyRalplanGate(keywords: KeywordType[], text: string):
gateApplied: boolean;
gatedKeywords: KeywordType[];
};
export {};
//# sourceMappingURL=index.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/keyword-detector/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAGL,KAAK,cAAc,EAEpB,MAAM,gCAAgC,CAAC;AAExC,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,OAAO,GACP,WAAW,GACX,MAAM,GACN,WAAW,GACX,SAAS,GACT,KAAK,GACL,aAAa,GACb,iBAAiB,GACjB,YAAY,GACZ,YAAY,GACZ,gBAAgB,GAChB,SAAS,GACT,OAAO,GACP,QAAQ,GACR,KAAK,CAAC;AAEV,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAmCD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CASrD;AAgID;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,QAE6D,CAAC;AAEnG;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBhE;AAoQD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC,GACpE,MAAM,CAKR;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,GAClB,eAAe,EAAE,CA0BnB;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAiB1D;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yDAAyD;IACzD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,0DAA0D;IAC1D,+BAA+B,CAAC,EAAE,OAAO,CAAC;CAC3C;AAED;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,cAAc,EAAE,cAAc,GAAG,IAAI,CAAC;IACtC,kBAAkB,EAAE,WAAW,EAAE,CAAC;CACnC;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,qBAA0B,GAClC,2BAA2B,CAoC7B;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAetE;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,kBAKlC,CAAC;AA6CH;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAsBlE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,WAAW,EAAE,EACvB,IAAI,EAAE,MAAM,GACX;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,WAAW,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,WAAW,EAAE,CAAA;CAAE,CAiCjF"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/keyword-detector/index.ts"],"names":[],"mappings":"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"}

81
dist/hooks/keyword-detector/index.js generated vendored
View File

@@ -38,6 +38,64 @@ const KEYWORD_PRIORITY = [
'ccg', 'ralplan', 'tdd', 'code-review', 'security-review',
'ultrathink', 'deepsearch', 'analyze', 'deep-interview', 'codex', 'gemini'
];
/**
* Canonical workflow skills detected via explicit slash invocation.
* Mirrors `CANONICAL_WORKFLOW_SKILLS` in `skill-state/index.ts`. Listed here
* (rather than imported) to keep the keyword-detector free of cross-module
* dependencies on skill-state.
*/
const CANONICAL_WORKFLOW_SLASH_SKILLS = [
'autopilot',
'ralph',
'team',
'ultrawork',
'ultraqa',
'deep-interview',
'ralplan',
'self-improve',
];
/**
* Map workflow slash skills to keyword types so explicit slash invocations
* surface alongside ordinary keyword detection. Skills with no dedicated
* KeywordType (`ultraqa`, `self-improve`) are intentionally absent — the
* bridge handles their seeding via the parser result instead of through the
* keyword-priority loop.
*/
const SLASH_SKILL_TO_KEYWORD_TYPE = {
autopilot: 'autopilot',
ralph: 'ralph',
team: 'team',
ultrawork: 'ultrawork',
'deep-interview': 'deep-interview',
ralplan: 'ralplan',
};
const WORKFLOW_SLASH_PATTERN = new RegExp('^\\s*/(?:oh-my-claudecode:|omc:)?(' +
CANONICAL_WORKFLOW_SLASH_SKILLS
.map((skill) => skill.replace(/-/g, '\\-'))
.join('|') +
')(?=\\s|$|[?!.,;:])', 'i');
/**
* Parse an explicit workflow slash invocation at the start of a prompt.
*
* Recognizes `/<skill>`, `/omc:<skill>`, and `/oh-my-claudecode:<skill>` for
* the canonical workflow skill list. Code fences and inline backticks are
* stripped first so quoted commands do not match. The trailing lookahead
* (whitespace, end-of-text, or punctuation) prevents file paths like
* `/ralph-logs/foo.txt` from matching `/ralph`.
*
* Returns `null` when no explicit invocation is present.
*/
export function parseExplicitWorkflowSlashInvocation(promptText) {
if (typeof promptText !== 'string' || promptText.length === 0)
return null;
const stripped = removeCodeBlocks(promptText);
const match = WORKFLOW_SLASH_PATTERN.exec(stripped);
if (!match)
return null;
const skill = match[1].toLowerCase();
const args = stripped.slice(match[0].length).trim();
return { skill, args, raw: match[0] };
}
/**
* Remove code blocks from text to prevent false positives
* Handles both fenced code blocks and inline code
@@ -398,6 +456,24 @@ export function extractPromptText(parts) {
*/
export function detectKeywordsWithType(text, _agentName) {
const detected = [];
// Check for an explicit canonical workflow slash invocation BEFORE sanitization.
// The general sanitizer strips bare `/word` tokens as file paths, so bare
// commands like `/ralph fix auth` would otherwise never match. This must be
// robust to surrounding whitespace, namespace prefixes (`/omc:`,
// `/oh-my-claudecode:`), and code-fence/backtick wrapping (handled inside
// the parser via removeCodeBlocks).
const explicitSlash = parseExplicitWorkflowSlashInvocation(text);
const explicitSlashType = explicitSlash
? SLASH_SKILL_TO_KEYWORD_TYPE[explicitSlash.skill]
: undefined;
if (explicitSlash && explicitSlashType) {
const position = Math.max(0, text.indexOf(explicitSlash.raw.trim()));
detected.push({
type: explicitSlashType,
keyword: explicitSlash.raw.trim(),
position,
});
}
const cleanedText = sanitizeForKeywordDetection(text);
// Check each keyword type
for (const type of KEYWORD_PRIORITY) {
@@ -405,6 +481,11 @@ export function detectKeywordsWithType(text, _agentName) {
if (type === 'team') {
continue;
}
// Skip the type that the explicit-slash detector already surfaced so we
// do not emit duplicate entries for the same intent.
if (explicitSlashType && type === explicitSlashType) {
continue;
}
const pattern = KEYWORD_PATTERNS[type];
const match = type === 'ralplan'
? findActionableRalplanMatch(cleanedText, pattern)

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/mode-registry/index.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/mode-registry/index.ts"],"names":[],"mappings":"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"}

65
dist/hooks/mode-registry/index.js generated vendored
View File

@@ -49,6 +49,16 @@ const MODE_CONFIGS = {
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAQA],
activeProperty: "active",
},
[MODE_NAMES.DEEP_INTERVIEW]: {
name: "Deep Interview",
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW],
activeProperty: "active",
},
[MODE_NAMES.SELF_IMPROVE]: {
name: "Self Improve",
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE],
activeProperty: "active",
},
};
// Export for use in other modules
export { MODE_CONFIGS };
@@ -98,9 +108,62 @@ export function getGlobalStateFilePath(_mode) {
return null;
}
/**
* Check if a JSON-based mode is active by reading its state file
* Workflow-slot tombstone TTL. Matches `WORKFLOW_TOMBSTONE_TTL_MS` in
* `src/hooks/skill-state/index.ts` — kept local here to preserve the
* "mode-registry uses ONLY file-based detection" invariant (no imports from
* hook modules that themselves depend on the registry).
*/
const WORKFLOW_SLOT_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Consult the session-local workflow ledger for a tombstoned slot.
*
* Returns `true` when the workflow ledger records the mode as tombstoned
* (soft-completed) AND the tombstone has not yet TTL-expired. Used to veto
* stale mode files from crashed sessions that never tore their own state down.
*
* Returns `false` for any shape we can't parse, any missing file, any live
* slot, and any slot whose tombstone already expired — so the legacy
* mode-file fallback remains authoritative whenever the ledger is silent.
*/
function isWorkflowSlotTombstonedForMode(cwd, mode, sessionId, now = Date.now()) {
try {
const ledgerPath = sessionId
? resolveSessionStatePath("skill-active", sessionId, cwd)
: join(getStateDir(cwd), "skill-active-state.json");
if (!existsSync(ledgerPath))
return false;
const raw = JSON.parse(readFileSync(ledgerPath, "utf-8"));
const slots = raw.active_skills;
if (!slots || typeof slots !== "object")
return false;
const slot = slots[mode];
if (!slot || typeof slot !== "object")
return false;
const completedAt = slot.completed_at;
if (typeof completedAt !== "string" || completedAt.length === 0)
return false;
const tombstonedAt = new Date(completedAt).getTime();
if (!Number.isFinite(tombstonedAt))
return false;
return now - tombstonedAt < WORKFLOW_SLOT_TOMBSTONE_TTL_MS;
}
catch {
return false;
}
}
/**
* Check if a JSON-based mode is active by reading its state file.
*
* Workflow-slot override: when the session workflow ledger records this mode
* as tombstoned (soft-completed), the stale per-mode state file is ignored so
* a fresh invocation can proceed without clearing artifacts manually. Live
* slots and absent slots both defer to the per-mode state file (legacy
* fallback preserved during the transition window).
*/
function isJsonModeActive(cwd, mode, sessionId) {
if (isWorkflowSlotTombstonedForMode(cwd, mode, sessionId)) {
return false;
}
const config = MODE_CONFIGS[mode];
// When sessionId is provided, ONLY check session-scoped path — no legacy fallback.
// This prevents cross-session state leakage where one session's legacy file

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
*
* Defines the supported execution modes and their state file locations.
*/
export type ExecutionMode = 'autopilot' | 'team' | 'ralph' | 'ultrawork' | 'ultraqa';
export type ExecutionMode = 'autopilot' | 'team' | 'ralph' | 'ultrawork' | 'ultraqa' | 'deep-interview' | 'self-improve';
export interface ModeConfig {
/** Display name for the mode */
name: string;

View File

@@ -1 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/hooks/mode-registry/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,CAAC;AAEd,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kDAAkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,aAAa,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/hooks/mode-registry/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,GACT,gBAAgB,GAChB,cAAc,CAAC;AAEnB,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kDAAkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,aAAa,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}

View File

@@ -209,5 +209,82 @@ describe('persistent-mode skill-state stop integration (issue #1033)', () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
// -----------------------------------------------------------------------
// Registry parity: deep-interview and self-improve canonical stop behavior
// -----------------------------------------------------------------------
it('blocks stop when deep-interview skill is actively executing', async () => {
const sessionId = 'session-di-stop-block';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, sessionId, 'deep-interview');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(true);
expect(result.message).toContain('deep-interview');
expect(result.message).toContain('SKILL ACTIVE');
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('blocks stop when self-improve skill is actively executing', async () => {
const sessionId = 'session-si-stop-block';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, sessionId, 'self-improve');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(true);
expect(result.message).toContain('self-improve');
expect(result.message).toContain('SKILL ACTIVE');
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('allows stop when deep-interview skill state is stale', async () => {
const sessionId = 'session-di-stop-stale';
const tempDir = makeTempProject();
try {
const past = new Date(Date.now() - 35 * 60 * 1000).toISOString(); // 35 min ago
writeSkillState(tempDir, sessionId, 'deep-interview', {
started_at: past,
last_checked_at: past,
stale_ttl_ms: 30 * 60 * 1000, // 30 min TTL (heavy protection)
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('allows stop when self-improve skill state is stale', async () => {
const sessionId = 'session-si-stop-stale';
const tempDir = makeTempProject();
try {
const past = new Date(Date.now() - 35 * 60 * 1000).toISOString();
writeSkillState(tempDir, sessionId, 'self-improve', {
started_at: past,
last_checked_at: past,
stale_ttl_ms: 30 * 60 * 1000,
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('respects session isolation for deep-interview skill state', async () => {
const sessionId = 'session-di-iso-a';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, 'session-di-iso-b', 'deep-interview');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
//# sourceMappingURL=skill-state-stop.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=workflow-gating.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"workflow-gating.test.d.ts","sourceRoot":"","sources":["../../../../src/hooks/persistent-mode/__tests__/workflow-gating.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { checkPersistentModes } from '../index.js';
function makeTempProject() {
const tempDir = mkdtempSync(join(tmpdir(), 'wf-gate-'));
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
return tempDir;
}
function writeWorkflowLedger(tempDir, sessionId, slots) {
const active_skills = {};
for (const [skill, opts] of Object.entries(slots)) {
active_skills[skill] = {
skill_name: skill,
started_at: new Date(Date.now() - 60_000).toISOString(),
completed_at: opts.completedAt ?? null,
session_id: sessionId,
mode_state_path: `${skill}-state.json`,
initialized_mode: skill,
initialized_state_path: join(tempDir, '.omc', 'state', 'skill-active-state.json'),
initialized_session_state_path: join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json'),
};
}
const payload = JSON.stringify({ version: 2, active_skills }, null, 2);
const rootDir = join(tempDir, '.omc', 'state');
mkdirSync(rootDir, { recursive: true });
writeFileSync(join(rootDir, 'skill-active-state.json'), payload);
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, 'skill-active-state.json'), payload);
}
function writeRalphState(tempDir, sessionId) {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, 'ralph-state.json'), JSON.stringify({
active: true,
iteration: 1,
max_iterations: 10,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
prompt: 'Test task',
session_id: sessionId,
project_path: tempDir,
linked_ultrawork: false,
}, null, 2));
}
describe('workflow-gating: kill switches (spec i)', () => {
let savedDisableOmc;
let savedSkipHooks;
beforeEach(() => {
savedDisableOmc = process.env.DISABLE_OMC;
savedSkipHooks = process.env.OMC_SKIP_HOOKS;
});
afterEach(() => {
if (savedDisableOmc === undefined) {
delete process.env.DISABLE_OMC;
}
else {
process.env.DISABLE_OMC = savedDisableOmc;
}
if (savedSkipHooks === undefined) {
delete process.env.OMC_SKIP_HOOKS;
}
else {
process.env.OMC_SKIP_HOOKS = savedSkipHooks;
}
});
it('DISABLE_OMC=1 bypasses all stop gating', async () => {
process.env.DISABLE_OMC = '1';
const result = await checkPersistentModes('kill-sw-1', undefined);
expect(result.shouldBlock).toBe(false);
expect(result.mode).toBe('none');
});
it('DISABLE_OMC=true bypasses all stop gating', async () => {
process.env.DISABLE_OMC = 'true';
const result = await checkPersistentModes('kill-sw-2', undefined);
expect(result.shouldBlock).toBe(false);
});
it('OMC_SKIP_HOOKS=persistent-mode bypasses stop gating', async () => {
process.env.OMC_SKIP_HOOKS = 'persistent-mode';
const result = await checkPersistentModes('kill-sw-3', undefined);
expect(result.shouldBlock).toBe(false);
expect(result.mode).toBe('none');
});
it('OMC_SKIP_HOOKS=stop-continuation bypasses stop gating', async () => {
process.env.OMC_SKIP_HOOKS = 'stop-continuation';
const result = await checkPersistentModes('kill-sw-4', undefined);
expect(result.shouldBlock).toBe(false);
});
it('OMC_SKIP_HOOKS with comma-separated list bypasses when persistent-mode is included', async () => {
process.env.OMC_SKIP_HOOKS = 'some-hook,persistent-mode,other-hook';
const result = await checkPersistentModes('kill-sw-5', undefined);
expect(result.shouldBlock).toBe(false);
});
});
describe('workflow-gating: tombstoned slot suppresses stale mode files (spec j)', () => {
it('tombstoned ralph slot suppresses ralph-state.json check', async () => {
const sessionId = 'tomb-ralph-01';
const tempDir = makeTempProject();
try {
// Write a ralph-state.json that would block if the workflow slot were live
writeRalphState(tempDir, sessionId);
// Write workflow ledger with ralph slot tombstoned
writeWorkflowLedger(tempDir, sessionId, {
'ralph': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
// Tombstoned ralph slot → runRalphPriority() returns null → no block
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned autopilot slot suppresses autopilot mode check', async () => {
const sessionId = 'tomb-auto-01';
const tempDir = makeTempProject();
try {
// Write autopilot-state.json in session state dir
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, 'autopilot-state.json'), JSON.stringify({
active: true,
iteration: 1,
max_iterations: 5,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
session_id: sessionId,
project_path: tempDir,
phase: 'plan',
prd: { stories: [] },
}, null, 2));
// Tombstone the autopilot slot
writeWorkflowLedger(tempDir, sessionId, {
'autopilot': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned ralplan slot suppresses ralplan mode check', async () => {
const sessionId = 'tomb-ralplan-01';
const tempDir = makeTempProject();
try {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, 'ralplan-state.json'), JSON.stringify({
active: true,
phase: 'planner',
session_id: sessionId,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
awaiting_confirmation: false,
}, null, 2));
writeWorkflowLedger(tempDir, sessionId, {
'ralplan': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned ultrawork slot suppresses ultrawork mode check', async () => {
const sessionId = 'tomb-ulw-01';
const tempDir = makeTempProject();
try {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, 'ultrawork-state.json'), JSON.stringify({
active: true,
session_id: sessionId,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
awaiting_confirmation: false,
tasks: [],
current_task_index: 0,
}, null, 2));
writeWorkflowLedger(tempDir, sessionId, {
'ultrawork': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('live ralph slot without tombstone blocks (control: tombstone guard is doing the work)', async () => {
const sessionId = 'tomb-ctrl-01';
const tempDir = makeTempProject();
try {
writeRalphState(tempDir, sessionId);
// Write workflow ledger with ralph slot LIVE (no completed_at)
writeWorkflowLedger(tempDir, sessionId, {
'ralph': {},
});
const result = await checkPersistentModes(sessionId, tempDir);
// Ralph-state.json is active + slot is live → should block
expect(result.shouldBlock).toBe(true);
expect(result.mode).toBe('ralph');
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('workflow-gating: authority-first ordering for nested skills (spec f)', () => {
it('returns shouldBlock=false when no active mode state files exist regardless of empty ledger', async () => {
const sessionId = 'auth-empty-01';
const tempDir = makeTempProject();
try {
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('autopilot workflow authority resolved from ledger root slot (spec f invariant)', async () => {
const sessionId = 'auth-ap-01';
const tempDir = makeTempProject();
try {
// Write autopilot as root with ralph as tombstoned child — ledger authority = autopilot
writeWorkflowLedger(tempDir, sessionId, {
'autopilot': {},
'ralph': { completedAt: new Date(Date.now() - 30_000).toISOString() },
});
// No mode state files → no actual blocking (tests the routing path, not blocking)
const result = await checkPersistentModes(sessionId, tempDir);
// Without autopilot-state.json, autopilot check returns null → result is shouldBlock=false
expect(result.shouldBlock).toBe(false);
}
finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
//# sourceMappingURL=workflow-gating.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/persistent-mode/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAsCH,OAAO,EAA4C,WAAW,EAAoG,MAAM,+BAA+B,CAAC;AASxM,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAGtE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,sCAAsC;IACtC,WAAW,EAAE,OAAO,CAAC;IACrB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,mBAAmB,GAAG,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IAC9F,0BAA0B;IAC1B,QAAQ,CAAC,EAAE;QACT,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,SAAS,CAAC,EAAE,cAAc,CAAC;KAC5B,CAAC;CACH;AAUD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAElF;AA2ED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CA8B1E;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAW3D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,cAAc,GAAG,IAAI,GAAG,MAAM,CAoClF;AAaD;;GAEG;AACH,wBAAgB,6BAA6B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAErE;AAED;;;GAGG;AACH,wBAAgB,kCAAkC,IAAI,MAAM,CAc3D;AA+DD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,OAAO,CAET;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,OAAO,CAsBT;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,IAAI,CAiBN;AA8lCD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,oBAAoB,CAAC,CAuJ/B;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,oBAAoB,GAAG;IAC9D,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAKA"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/persistent-mode/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAsCH,OAAO,EAA4C,WAAW,EAAoG,MAAM,+BAA+B,CAAC;AASxM,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAGtE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,sCAAsC;IACtC,WAAW,EAAE,OAAO,CAAC;IACrB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,mBAAmB,GAAG,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IAC9F,0BAA0B;IAC1B,QAAQ,CAAC,EAAE;QACT,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,SAAS,CAAC,EAAE,cAAc,CAAC;KAC5B,CAAC;CACH;AAUD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAElF;AA2ED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CA8B1E;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAW3D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,cAAc,GAAG,IAAI,GAAG,MAAM,CAoClF;AAaD;;GAEG;AACH,wBAAgB,6BAA6B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAErE;AAED;;;GAGG;AACH,wBAAgB,kCAAkC,IAAI,MAAM,CAc3D;AA+DD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,OAAO,CAET;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,OAAO,CAsBT;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,yBAAyB,GAAG,IAAI,GAC3C,IAAI,CAiBN;AA8lCD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,oBAAoB,CAAC,CAiP/B;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,oBAAoB,GAAG;IAC9D,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAKA"}

151
dist/hooks/persistent-mode/index.js generated vendored
View File

@@ -1228,6 +1228,37 @@ ${TODO_CONTINUATION_PROMPT}
export async function checkPersistentModes(sessionId, directory, stopContext // NEW: from todo-continuation types
) {
const workingDir = resolveToWorktreeRoot(directory);
// Hard bypass invariants: never enforce stop continuation under any of these
// environment-level kill switches. bridge.ts also guards DISABLE_OMC and
// OMC_SKIP_HOOKS at hook-entry, but we re-check here so direct callers and
// nested helpers (team workers, tests) observe the same contract.
if (process.env.DISABLE_OMC === '1' ||
process.env.DISABLE_OMC === 'true' ||
process.env.OMC_TEAM_WORKER) {
return { shouldBlock: false, message: '', mode: 'none' };
}
const skipHooks = (process.env.OMC_SKIP_HOOKS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (skipHooks.includes('persistent-mode') || skipHooks.includes('stop-continuation')) {
return { shouldBlock: false, message: '', mode: 'none' };
}
// Best-effort: prune expired tombstones so stale completion markers do not
// linger past their TTL and mask a fresh invocation. Never let a prune
// failure interfere with stop enforcement.
try {
const { readSkillActiveStateNormalized, pruneExpiredWorkflowSkillTombstones, writeSkillActiveStateCopies } = await import('../skill-state/index.js');
const current = readSkillActiveStateNormalized(workingDir, sessionId);
const pruned = pruneExpiredWorkflowSkillTombstones(current);
if (pruned !== current) {
writeSkillActiveStateCopies(workingDir, pruned, sessionId);
}
}
catch {
// Skill-state module unavailable or ledger unreadable — continue with
// legacy priority enforcement.
}
// CRITICAL: Never block context-limit/critical-context stops.
// Blocking these causes a deadlock where Claude Code cannot compact or exit.
// See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213
@@ -1293,50 +1324,108 @@ export async function checkPersistentModes(sessionId, directory, stopContext //
// Note: stopContext already checked above, but pass it for consistency
const todoResult = await checkIncompleteTodos(sessionId, workingDir, stopContext);
const hasIncompleteTodos = todoResult.count > 0;
// Priority 1: Ralph (explicit loop mode)
const ralphResult = await checkRalphLoop(sessionId, workingDir, cancelInProgress);
if (ralphResult) {
return ralphResult;
}
// Priority 1.5: Autopilot (full orchestration mode - higher than ultrawork, lower than ralph)
if (isAutopilotActive(workingDir, sessionId)) {
const autopilotResult = await checkAutopilot(sessionId, workingDir);
if (autopilotResult?.shouldBlock) {
return {
shouldBlock: true,
message: autopilotResult.message,
mode: 'autopilot',
metadata: {
iteration: autopilotResult.metadata?.iteration,
maxIterations: autopilotResult.metadata?.maxIterations,
phase: autopilotResult.phase,
tasksCompleted: autopilotResult.metadata?.tasksCompleted,
tasksTotal: autopilotResult.metadata?.tasksTotal,
toolError: autopilotResult.metadata?.toolError
}
};
// Consult the workflow ledger ONCE before direct mode-priority shortcuts.
// `resolveAuthoritativeWorkflowSkill()` returns the root of the live chain
// (autopilot in `autopilot → ralph`), so stop enforcement bubbles up to the
// live parent rather than the child currently executing beneath it.
// Tombstoned slots are tracked separately so stale mode files from crashed
// sessions don't re-arm priority checks until TTL prune or fresh activation.
const tombstonedWorkflowModes = new Set();
let workflowAuthority = null;
try {
const { readSkillActiveStateNormalized, resolveAuthoritativeWorkflowSkill } = await import('../skill-state/index.js');
const ledger = readSkillActiveStateNormalized(workingDir, sessionId);
const authority = resolveAuthoritativeWorkflowSkill(ledger);
workflowAuthority = authority?.skill_name ?? null;
for (const [name, slot] of Object.entries(ledger.active_skills)) {
if (slot.completed_at)
tombstonedWorkflowModes.add(name);
}
}
catch {
// Ledger unavailable — fall back to legacy mode-file detection.
}
// Authority-first ordering for nested workflow runs.
//
// `resolveAuthoritativeWorkflowSkill()` returns the root of the live chain.
// In `autopilot → ralph`, autopilot is the authoritative parent while ralph
// runs beneath it — stop enforcement must resolve to the live parent so its
// iteration accounting keeps advancing. The legacy ordering (ralph > autopilot)
// still applies whenever the ledger is silent or authority already is ralph.
const autopilotPriorityFirst = workflowAuthority === 'autopilot';
const runAutopilotPriority = async () => {
if (tombstonedWorkflowModes.has('autopilot') ||
!isAutopilotActive(workingDir, sessionId)) {
return null;
}
const autopilotResult = await checkAutopilot(sessionId, workingDir);
if (!autopilotResult?.shouldBlock)
return null;
return {
shouldBlock: true,
message: autopilotResult.message,
mode: 'autopilot',
metadata: {
iteration: autopilotResult.metadata?.iteration,
maxIterations: autopilotResult.metadata?.maxIterations,
phase: autopilotResult.phase,
tasksCompleted: autopilotResult.metadata?.tasksCompleted,
tasksTotal: autopilotResult.metadata?.tasksTotal,
toolError: autopilotResult.metadata?.toolError,
},
};
};
const runRalphPriority = async () => {
// Skip when the ralph workflow slot is tombstoned — a stale `ralph-state.json`
// from a crashed session must not block a fresh invocation.
if (tombstonedWorkflowModes.has('ralph'))
return null;
return checkRalphLoop(sessionId, workingDir, cancelInProgress);
};
if (autopilotPriorityFirst) {
const autopilotResult = await runAutopilotPriority();
if (autopilotResult)
return autopilotResult;
const ralphResult = await runRalphPriority();
if (ralphResult)
return ralphResult;
}
else {
const ralphResult = await runRalphPriority();
if (ralphResult)
return ralphResult;
const autopilotResult = await runAutopilotPriority();
if (autopilotResult)
return autopilotResult;
}
// Priority 1.7: Ralplan (standalone consensus planning)
// Ralplan consensus loops (Planner/Architect/Critic) need hard-blocking.
// When ralplan runs under ralph, checkRalphLoop() handles it (Priority 1).
// Return ANY non-null result (including circuit breaker shouldBlock=false with message).
const ralplanResult = await checkRalplan(sessionId, workingDir, cancelInProgress);
if (ralplanResult) {
return ralplanResult;
// Suppressed when the ralplan slot is tombstoned so noisy re-handoff stops
// on completion until the tombstone TTL expires or a fresh slot reopens.
if (!tombstonedWorkflowModes.has('ralplan')) {
const ralplanResult = await checkRalplan(sessionId, workingDir, cancelInProgress);
if (ralplanResult) {
return ralplanResult;
}
}
// Priority 1.8: Team Pipeline (standalone team mode)
// When team runs without ralph, this provides stop-hook blocking.
// When team runs with ralph, checkRalphLoop() handles it (Priority 1).
// Return ANY non-null result (including circuit breaker shouldBlock=false with message).
const teamResult = await checkTeamPipeline(sessionId, workingDir, cancelInProgress);
if (teamResult) {
return teamResult;
if (!tombstonedWorkflowModes.has('team')) {
const teamResult = await checkTeamPipeline(sessionId, workingDir, cancelInProgress);
if (teamResult) {
return teamResult;
}
}
// Priority 2: Ultrawork Mode (performance mode with persistence)
const ultraworkResult = await checkUltrawork(sessionId, workingDir, hasIncompleteTodos, cancelInProgress);
if (ultraworkResult) {
return ultraworkResult;
if (!tombstonedWorkflowModes.has('ultrawork')) {
const ultraworkResult = await checkUltrawork(sessionId, workingDir, hasIncompleteTodos, cancelInProgress);
if (ultraworkResult) {
return ultraworkResult;
}
}
// Priority 3: Skill Active State (issue #1033)
// Skills like code-review, plan, tdd, etc. write skill-active-state.json

File diff suppressed because one or more lines are too long

2
dist/hooks/recovery/types.d.ts generated vendored
View File

@@ -34,7 +34,7 @@ export interface ParsedTokenLimitError {
errorType: string;
/** Provider ID (e.g., 'anthropic') */
providerID?: string;
/** Model ID (e.g., 'claude-opus-4-6') */
/** Model ID (e.g., 'claude-opus-4-7') */
modelID?: string;
/** Index of the problematic message */
messageIndex?: number;

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { getSkillProtection, getSkillConfig, readSkillActiveState, writeSkillActiveState, clearSkillActiveState, isSkillStateStale, checkSkillActiveState, } from '../index.js';
import { getSkillProtection, getSkillConfig, readSkillActiveState, writeSkillActiveState, clearSkillActiveState, isSkillStateStale, checkSkillActiveState, readSkillActiveStateNormalized, writeSkillActiveStateCopies, upsertWorkflowSkillSlot, markWorkflowSkillCompleted, clearWorkflowSkillSlot, pruneExpiredWorkflowSkillTombstones, resolveAuthoritativeWorkflowSkill, emptySkillActiveStateV2, WORKFLOW_TOMBSTONE_TTL_MS, } from '../index.js';
function makeTempDir() {
const tempDir = mkdtempSync(join(tmpdir(), 'skill-state-'));
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
@@ -447,5 +447,539 @@ describe('skill-state', () => {
expect(finalCheck.shouldBlock).toBe(false);
});
});
// -----------------------------------------------------------------------
// writeSkillActiveStateCopies — dual-write invariant (spec a/b)
// -----------------------------------------------------------------------
describe('writeSkillActiveStateCopies — dual-write invariant (spec a/b)', () => {
const rootFilePath = (dir) => join(dir, '.omc', 'state', 'skill-active-state.json');
const sessionFilePath = (dir, sid) => join(dir, '.omc', 'state', 'sessions', sid, 'skill-active-state.json');
it('writes both root and session copies on seed', () => {
const sessionId = 'dwc-seed-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, state, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(true);
});
it('both copies contain identical slot content after seed', () => {
const sessionId = 'dwc-parity-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'autopilot', {
session_id: sessionId,
mode_state_path: 'autopilot-state.json',
initialized_mode: 'autopilot',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, state, sessionId);
const root = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8'));
const session = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8'));
expect(root.active_skills['autopilot']).toBeDefined();
expect(session.active_skills['autopilot']).toBeDefined();
expect(root.active_skills['autopilot']?.session_id).toBe(sessionId);
expect(session.active_skills['autopilot']?.session_id).toBe(sessionId);
});
it('writes only root copy when sessionId is omitted', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 'anon',
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: '',
});
writeSkillActiveStateCopies(tempDir, state);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(join(tempDir, '.omc', 'state', 'sessions'))).toBe(false);
});
it('both copies reflect tombstone after markWorkflowSkillCompleted (spec b)', () => {
const sessionId = 'dwc-tomb-01';
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, seeded, sessionId);
const tombstoneTime = '2026-04-17T10:00:00.000Z';
const tombstoned = markWorkflowSkillCompleted(seeded, 'ralph', tombstoneTime);
writeSkillActiveStateCopies(tempDir, tombstoned, sessionId);
const root = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8'));
const session = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8'));
expect(root.active_skills['ralph']?.completed_at).toBe(tombstoneTime);
expect(session.active_skills['ralph']?.completed_at).toBe(tombstoneTime);
});
it('removes both files when all slots cleared (spec b cancel)', () => {
const sessionId = 'dwc-cancel-01';
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, seeded, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(true);
const cleared = clearWorkflowSkillSlot(seeded, 'ralph');
writeSkillActiveStateCopies(tempDir, cleared, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(false);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(false);
});
it('returns true on successful dual-write', () => {
const sessionId = 'dwc-ok-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ultrawork', {
session_id: sessionId,
mode_state_path: 'ultrawork-state.json',
initialized_mode: 'ultrawork',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
const result = writeSkillActiveStateCopies(tempDir, state, sessionId);
expect(result).toBe(true);
});
});
// -----------------------------------------------------------------------
// readSkillActiveStateNormalized — v1 scalar + v2 normalization (spec a)
// -----------------------------------------------------------------------
describe('readSkillActiveStateNormalized — normalization and session authority', () => {
it('returns empty v2 when no files exist', () => {
const state = readSkillActiveStateNormalized(tempDir, 'no-session');
expect(state.version).toBe(2);
expect(Object.keys(state.active_skills)).toHaveLength(0);
});
it('normalizes v1 scalar payload into support_skill branch', () => {
const stateDir = join(tempDir, '.omc', 'state');
mkdirSync(stateDir, { recursive: true });
const v1 = {
active: true,
skill_name: 'plan',
session_id: 'v1-sess',
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
reinforcement_count: 0,
max_reinforcements: 5,
stale_ttl_ms: 15 * 60 * 1000,
};
writeFileSync(join(stateDir, 'skill-active-state.json'), JSON.stringify(v1, null, 2));
const normalized = readSkillActiveStateNormalized(tempDir);
expect(normalized.version).toBe(2);
expect(normalized.support_skill?.skill_name).toBe('plan');
expect(Object.keys(normalized.active_skills)).toHaveLength(0);
});
it('session copy is authoritative for session-local reads', () => {
const sessionId = 'norm-auth-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const rootState = {
version: 2,
active_skills: {
'autopilot': {
skill_name: 'autopilot',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: 'other-session',
mode_state_path: '',
initialized_mode: 'autopilot',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
const sessionState = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: '',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
writeFileSync(join(rootDir, 'skill-active-state.json'), JSON.stringify(rootState));
writeFileSync(join(sessionDir, 'skill-active-state.json'), JSON.stringify(sessionState));
const result = readSkillActiveStateNormalized(tempDir, sessionId);
expect(result.active_skills['ralph']).toBeDefined();
expect(result.active_skills['autopilot']).toBeUndefined();
});
it('returns empty state when sessionId provided but no session copy exists (no cross-session leak)', () => {
const rootDir = join(tempDir, '.omc', 'state');
mkdirSync(rootDir, { recursive: true });
const rootState = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: 'root-only',
mode_state_path: '',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
writeFileSync(join(rootDir, 'skill-active-state.json'), JSON.stringify(rootState));
const result = readSkillActiveStateNormalized(tempDir, 'different-session');
expect(Object.keys(result.active_skills)).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// pruneExpiredWorkflowSkillTombstones — TTL sweep (spec c)
// -----------------------------------------------------------------------
describe('pruneExpiredWorkflowSkillTombstones — TTL sweep (spec c)', () => {
const makeSlot = (skillName, completedAt) => ({
skill_name: skillName,
started_at: '2026-04-17T00:00:00.000Z',
completed_at: completedAt ?? null,
session_id: 'prune-session',
mode_state_path: `${skillName}-state.json`,
initialized_mode: skillName,
initialized_state_path: '',
initialized_session_state_path: '',
});
it('removes tombstoned slots past TTL', () => {
const past = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); // 25h ago
const state = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph', past) },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state, WORKFLOW_TOMBSTONE_TTL_MS);
expect(pruned.active_skills['ralph']).toBeUndefined();
});
it('keeps tombstoned slots within TTL', () => {
const recent = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); // 1h ago
const state = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph', recent) },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state, WORKFLOW_TOMBSTONE_TTL_MS);
expect(pruned.active_skills['ralph']).toBeDefined();
});
it('never removes live (non-tombstoned) slots', () => {
const state = {
version: 2,
active_skills: { 'autopilot': makeSlot('autopilot') },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['autopilot']).toBeDefined();
});
it('prunes only stale tombstones, keeps fresh tombstones and live slots', () => {
const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
const fresh = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
const state = {
version: 2,
active_skills: {
'ralph': makeSlot('ralph', old),
'autopilot': makeSlot('autopilot', fresh),
'ultrawork': makeSlot('ultrawork'),
},
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['ralph']).toBeUndefined();
expect(pruned.active_skills['autopilot']).toBeDefined();
expect(pruned.active_skills['ultrawork']).toBeDefined();
});
it('returns same reference when nothing changed', () => {
const state = {
version: 2,
active_skills: { 'autopilot': makeSlot('autopilot') },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned).toBe(state);
});
it('keeps slot with malformed completed_at defensively', () => {
const state = {
version: 2,
active_skills: { 'ralph': { ...makeSlot('ralph'), completed_at: 'not-a-date' } },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['ralph']).toBeDefined();
});
it('WORKFLOW_TOMBSTONE_TTL_MS equals 24 hours', () => {
expect(WORKFLOW_TOMBSTONE_TTL_MS).toBe(24 * 60 * 60 * 1000);
});
});
// -----------------------------------------------------------------------
// resolveAuthoritativeWorkflowSkill — nested lineage (spec f)
// -----------------------------------------------------------------------
describe('resolveAuthoritativeWorkflowSkill — nested lineage (spec f)', () => {
const makeSlot = (skillName, opts = {}) => ({
skill_name: skillName,
started_at: new Date().toISOString(),
completed_at: null,
session_id: 'nest-session',
mode_state_path: `${skillName}-state.json`,
initialized_mode: skillName,
initialized_state_path: '',
initialized_session_state_path: '',
...opts,
});
it('returns null when no slots', () => {
expect(resolveAuthoritativeWorkflowSkill(emptySkillActiveStateV2())).toBeNull();
});
it('returns null when all slots are tombstoned', () => {
const state = {
version: 2,
active_skills: {
'ralph': makeSlot('ralph', { completed_at: '2026-04-17T00:00:00Z' }),
},
};
expect(resolveAuthoritativeWorkflowSkill(state)).toBeNull();
});
it('returns the single live slot', () => {
const state = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph') },
};
expect(resolveAuthoritativeWorkflowSkill(state)?.skill_name).toBe('ralph');
});
it('returns autopilot (outer root) while ralph (child) is live beneath it', () => {
const autopilotStarted = new Date(Date.now() - 5000).toISOString();
const ralphStarted = new Date().toISOString();
const state = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot', { started_at: autopilotStarted }),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot', started_at: ralphStarted }),
},
};
const result = resolveAuthoritativeWorkflowSkill(state);
expect(result?.skill_name).toBe('autopilot');
});
it('ralph tombstone does not affect autopilot; autopilot stays authoritative', () => {
const autopilotStarted = new Date(Date.now() - 5000).toISOString();
const state = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot', { started_at: autopilotStarted }),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot', completed_at: '2026-04-17T00:00:00Z' }),
},
};
const result = resolveAuthoritativeWorkflowSkill(state);
expect(result?.skill_name).toBe('autopilot');
expect(result?.completed_at).toBeFalsy();
});
it('autopilot completed_at stays unset while ralph is active beneath it (spec f invariant)', () => {
const state = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot'),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot' }),
},
};
expect(state.active_skills['autopilot']?.completed_at).toBeFalsy();
expect(resolveAuthoritativeWorkflowSkill(state)?.skill_name).toBe('autopilot');
});
});
// -----------------------------------------------------------------------
// Diverged-copy reconciliation (spec d)
// -----------------------------------------------------------------------
describe('diverged-copy reconciliation (spec d)', () => {
const rootFilePath = (dir) => join(dir, '.omc', 'state', 'skill-active-state.json');
const sessionFilePath = (dir, sid) => join(dir, '.omc', 'state', 'sessions', sid, 'skill-active-state.json');
it('session copy is authoritative when root and session copies diverge', () => {
const sessionId = 'drift-auth-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const baseSlot = {
skill_name: 'ralph',
started_at: '2026-04-17T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
};
const staleRootState = { version: 2, active_skills: { 'ralph': baseSlot } };
const freshSessionState = {
version: 2,
active_skills: { 'ralph': { ...baseSlot, last_confirmed_at: '2026-04-17T01:00:00Z' } },
};
writeFileSync(rootFilePath(tempDir), JSON.stringify(staleRootState));
writeFileSync(sessionFilePath(tempDir, sessionId), JSON.stringify(freshSessionState));
const result = readSkillActiveStateNormalized(tempDir, sessionId);
expect(result.active_skills['ralph']?.last_confirmed_at).toBe('2026-04-17T01:00:00Z');
});
it('next writeSkillActiveStateCopies re-syncs diverged copies', () => {
const sessionId = 'drift-resync-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const baseSlot = {
skill_name: 'ralph',
started_at: '2026-04-17T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
};
writeFileSync(rootFilePath(tempDir), JSON.stringify({ version: 2, active_skills: { 'ralph': baseSlot } }));
writeFileSync(sessionFilePath(tempDir, sessionId), JSON.stringify({
version: 2,
active_skills: { 'ralph': { ...baseSlot, last_confirmed_at: '2026-04-17T01:00:00Z' } },
}));
// Next mutation: tombstone via session-authoritative read → dual-write reconciles
const current = readSkillActiveStateNormalized(tempDir, sessionId);
const tombstoned = markWorkflowSkillCompleted(current, 'ralph');
writeSkillActiveStateCopies(tempDir, tombstoned, sessionId);
const rootAfter = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8'));
const sessionAfter = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8'));
expect(rootAfter.active_skills['ralph']?.completed_at).toBeTruthy();
expect(sessionAfter.active_skills['ralph']?.completed_at).toBeTruthy();
});
});
// -----------------------------------------------------------------------
// Pure workflow-slot helpers — unit tests
// -----------------------------------------------------------------------
describe('upsertWorkflowSkillSlot — pure helper', () => {
it('creates a new slot with provided fields', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(state.active_skills['ralph']?.skill_name).toBe('ralph');
expect(state.active_skills['ralph']?.session_id).toBe('s1');
});
it('preserves started_at on re-upsert (idempotent seed)', () => {
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 's1',
started_at: '2026-01-01T00:00:00Z',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
const confirmed = upsertWorkflowSkillSlot(seeded, 'ralph', {
last_confirmed_at: '2026-04-17T00:00:00Z',
});
expect(confirmed.active_skills['ralph']?.started_at).toBe('2026-01-01T00:00:00Z');
expect(confirmed.active_skills['ralph']?.last_confirmed_at).toBe('2026-04-17T00:00:00Z');
});
it('strips oh-my-claudecode: prefix from skill name', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'oh-my-claudecode:ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(state.active_skills['ralph']).toBeDefined();
expect(state.active_skills['oh-my-claudecode:ralph']).toBeUndefined();
});
it('does not mutate the original state object', () => {
const original = emptySkillActiveStateV2();
upsertWorkflowSkillSlot(original, 'ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(Object.keys(original.active_skills)).toHaveLength(0);
});
});
describe('markWorkflowSkillCompleted — pure helper', () => {
it('sets completed_at to provided timestamp', () => {
const ts = '2026-04-17T12:00:00.000Z';
const state = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const tombstoned = markWorkflowSkillCompleted(state, 'ralph', ts);
expect(tombstoned.active_skills['ralph']?.completed_at).toBe(ts);
});
it('returns state unchanged when slot is absent (idempotent)', () => {
const state = emptySkillActiveStateV2();
const result = markWorkflowSkillCompleted(state, 'ralph');
expect(result).toBe(state);
});
it('does not tombstone sibling slots', () => {
const state = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
'autopilot': {
skill_name: 'autopilot', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'autopilot',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const tombstoned = markWorkflowSkillCompleted(state, 'ralph');
expect(tombstoned.active_skills['autopilot']?.completed_at).toBeFalsy();
});
});
describe('clearWorkflowSkillSlot — pure helper', () => {
it('removes the slot entirely', () => {
const state = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const cleared = clearWorkflowSkillSlot(state, 'ralph');
expect(cleared.active_skills['ralph']).toBeUndefined();
});
it('is idempotent when slot is absent', () => {
const state = emptySkillActiveStateV2();
const result = clearWorkflowSkillSlot(state, 'ralph');
expect(result).toBe(state);
});
it('does not remove sibling slots', () => {
const state = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
'autopilot': {
skill_name: 'autopilot', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'autopilot',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const cleared = clearWorkflowSkillSlot(state, 'ralph');
expect(cleared.active_skills['autopilot']).toBeDefined();
});
});
});
//# sourceMappingURL=skill-state.test.js.map

File diff suppressed because one or more lines are too long

205
dist/hooks/skill-state/index.d.ts generated vendored
View File

@@ -1,20 +1,51 @@
/**
* Skill Active State Management
* Skill Active State Management (v2 mixed schema)
*
* Tracks when a skill is actively executing so the persistent-mode Stop hook
* can prevent premature session termination.
* `skill-active-state.json` is a dual-copy workflow ledger:
*
* Skills like plan, external-context, deepinit etc. don't write mode state
* files (ralph-state.json, etc.), so the Stop hook previously had no way to
* know they were running.
* {
* "version": 2,
* "active_skills": { // workflow-slot ledger
* "<canonical workflow skill>": {
* "skill_name": ...,
* "started_at": ...,
* "completed_at": ..., // soft tombstone
* "parent_skill": ..., // lineage for nested runs
* "session_id": ...,
* "mode_state_path": ...,
* "initialized_mode": ...,
* "initialized_state_path": ...,
* "initialized_session_state_path": ...
* }
* },
* "support_skill": { // legacy-compatible branch
* "active": true, "skill_name": "plan", ...
* }
* }
*
* This module provides:
* 1. A protection level registry for all skills (none/light/medium/heavy)
* 2. Read/write/clear functions for skill-active-state.json
* 3. A check function for the Stop hook to determine if blocking is needed
*
* Fix for: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1033
* HARD INVARIANTS:
* 1. `writeSkillActiveStateCopies()` is the only helper allowed to persist
* workflow-slot state. Every workflow-slot write, confirm, tombstone, TTL
* pruning, and hard-clear must update BOTH
* - `.omc/state/skill-active-state.json`
* - `.omc/state/sessions/{sessionId}/skill-active-state.json`
* together through this single helper.
* 2. Support-skill writes go through the same helper so the shared file
* never drops the `active_skills` branch.
* 3. The session copy is authoritative for session-local reads; the root
* copy is authoritative for cross-session aggregation.
*/
export declare const SKILL_ACTIVE_STATE_MODE = "skill-active";
export declare const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
export declare const WORKFLOW_TOMBSTONE_TTL_MS: number;
/**
* Canonical workflow skills — the only skills that get workflow slots.
* Non-workflow skills keep today's `light/medium/heavy` protection via the
* `support_skill` branch.
*/
export declare const CANONICAL_WORKFLOW_SKILLS: readonly ["autopilot", "ralph", "team", "ultrawork", "ultraqa", "deep-interview", "ralplan", "self-improve"];
export type CanonicalWorkflowSkill = typeof CANONICAL_WORKFLOW_SKILLS[number];
export declare function isCanonicalWorkflowSkill(skillName: string): skillName is CanonicalWorkflowSkill;
export type SkillProtectionLevel = 'none' | 'light' | 'medium' | 'heavy';
export interface SkillStateConfig {
/** Max stop-hook reinforcements before allowing stop */
@@ -22,6 +53,9 @@ export interface SkillStateConfig {
/** Time-to-live in ms before state is considered stale */
staleTtlMs: number;
}
export declare function getSkillProtection(skillName: string, rawSkillName?: string): SkillProtectionLevel;
export declare function getSkillConfig(skillName: string, rawSkillName?: string): SkillStateConfig;
/** Legacy-compatible support-skill state shape (unchanged from v1). */
export interface SkillActiveState {
active: boolean;
skill_name: string;
@@ -32,53 +66,140 @@ export interface SkillActiveState {
max_reinforcements: number;
stale_ttl_ms: number;
}
/** A single workflow-slot entry keyed by canonical workflow skill name. */
export interface ActiveSkillSlot {
skill_name: string;
started_at: string;
/** Soft tombstone. `null`/undefined = live. ISO timestamp = tombstoned. */
completed_at?: string | null;
/** Last idempotent re-confirmation timestamp (post-tool). */
last_confirmed_at?: string;
/** Parent skill name for nested lineage (e.g. ralph under autopilot). */
parent_skill?: string | null;
session_id: string;
/** Absolute or relative path to the mode-specific state file. */
mode_state_path: string;
/** Mode to initialize alongside this slot (usually equals skill_name). */
initialized_mode: string;
/** Pointer to the root `skill-active-state.json` copy at write time. */
initialized_state_path: string;
/** Pointer to the session `skill-active-state.json` copy at write time. */
initialized_session_state_path: string;
/** Origin of the slot (e.g. 'prompt-submit', 'post-tool'). */
source?: string;
}
/** v2 mixed schema. */
export interface SkillActiveStateV2 {
version: 2;
active_skills: Record<string, ActiveSkillSlot>;
support_skill?: SkillActiveState | null;
}
export interface WriteSkillActiveStateCopiesOptions {
/**
* Override the root copy payload. Defaults to writing the same payload as
* the session copy. Pass `null` to explicitly delete the root copy while
* keeping the session copy.
*/
rootState?: SkillActiveStateV2 | null;
}
export declare function emptySkillActiveStateV2(): SkillActiveStateV2;
/** Upsert (create or update) a workflow slot on a v2 state. Pure. */
export declare function upsertWorkflowSkillSlot(state: SkillActiveStateV2, skillName: string, slotData?: Partial<ActiveSkillSlot>): SkillActiveStateV2;
/**
* Get the protection level for a skill.
*
* Only skills explicitly registered in SKILL_PROTECTION receive stop-hook
* protection. Unregistered skills (including external plugin skills like
* Anthropic's example-skills, document-skills, superpowers, data, etc.)
* default to 'none' so the Stop hook does not block them.
*
* @param skillName - The normalized (prefix-stripped) skill name.
* @param rawSkillName - The original skill name as invoked (e.g., 'oh-my-claudecode:plan'
* or 'plan'). When provided, only skills invoked with the 'oh-my-claudecode:' prefix
* are eligible for protection. This prevents project custom skills (e.g., a user's
* `.claude/skills/plan/`) from being confused with OMC built-in skills of the same name.
* See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1581
* Soft tombstone: set `completed_at` on an existing slot. Slot is retained
* until the TTL pruner removes it. Returns state unchanged when the slot is
* absent (idempotent).
*/
export declare function getSkillProtection(skillName: string, rawSkillName?: string): SkillProtectionLevel;
export declare function markWorkflowSkillCompleted(state: SkillActiveStateV2, skillName: string, now?: string): SkillActiveStateV2;
/** Hard-clear: remove a slot entirely (for explicit cancel). Pure. */
export declare function clearWorkflowSkillSlot(state: SkillActiveStateV2, skillName: string): SkillActiveStateV2;
/**
* Get the protection config for a skill.
* TTL prune: remove tombstoned slots whose `completed_at + ttlMs < now`.
* Called on UserPromptSubmit. Pure.
*/
export declare function getSkillConfig(skillName: string, rawSkillName?: string): SkillStateConfig;
export declare function pruneExpiredWorkflowSkillTombstones(state: SkillActiveStateV2, ttlMs?: number, now?: number): SkillActiveStateV2;
/**
* Read the current skill active state.
* Returns null if no state exists or state is invalid.
* Resolve the authoritative workflow slot for stop-hook and downstream
* consumers.
*
* Rule: among live (non-tombstoned) slots, prefer those whose parent lineage
* is absent or itself tombstoned (roots of the live chain). Among those,
* return the newest by `started_at`. In nested `autopilot → ralph` flows this
* returns `autopilot` while ralph is still live beneath it, so stop-hook
* enforcement keeps reinforcing the outer loop.
*/
export declare function resolveAuthoritativeWorkflowSkill(state: SkillActiveStateV2): ActiveSkillSlot | null;
/**
* Pure query: is the workflow slot for `skillName` live (non-tombstoned)?
* Returns false when no slot exists at all, so callers can distinguish
* "no ledger entry" from "tombstoned" via `isWorkflowSkillTombstoned`.
*/
export declare function isWorkflowSkillLive(state: SkillActiveStateV2, skillName: string): boolean;
/**
* Pure query: is the slot tombstoned (has `completed_at`) and not yet expired?
* Used by stop enforcement to suppress noisy re-handoff on completed workflows
* until TTL pruning removes the slot or a fresh invocation reactivates it.
*/
export declare function isWorkflowSkillTombstoned(state: SkillActiveStateV2, skillName: string, ttlMs?: number, now?: number): boolean;
/**
* Read the v2 mixed-schema workflow ledger, normalizing legacy scalar state
* into `support_skill` without dropping support-skill data.
*
* When `sessionId` is provided, the session copy is authoritative for
* session-local reads. No fall-through to the root copy, to prevent
* cross-session leakage. When no session copy exists for the session, the
* ledger is treated as empty for that session's local reads.
*
* When `sessionId` is omitted (legacy/global path), the root copy is read.
*
* Logs a reconciliation warning when the session copy diverges from the root
* for slots belonging to the same session. The next mutation through
* `writeSkillActiveStateCopies()` re-synchronizes both copies.
*/
export declare function readSkillActiveStateNormalized(directory: string, sessionId?: string): SkillActiveStateV2;
/**
* THE ONLY HELPER allowed to persist workflow-slot state.
*
* Writes BOTH root `.omc/state/skill-active-state.json` AND session
* `.omc/state/sessions/{sessionId}/skill-active-state.json` together. When a
* resolved state is empty (no slots, no support_skill), the corresponding
* file is removed instead — the absence of a file is the canonical empty
* state.
*
* @returns true when all writes / deletes succeeded, false otherwise.
*/
export declare function writeSkillActiveStateCopies(directory: string, nextState: SkillActiveStateV2, sessionId?: string, options?: WriteSkillActiveStateCopiesOptions): boolean;
/**
* Read the support-skill state as a legacy scalar `SkillActiveState`.
*
* Returns null when no support_skill entry is present in the v2 ledger.
* Workflow slots are intentionally NOT exposed through this function —
* downstream workflow consumers should call `readSkillActiveStateNormalized()`
* and `resolveAuthoritativeWorkflowSkill()` instead.
*/
export declare function readSkillActiveState(directory: string, sessionId?: string): SkillActiveState | null;
/**
* Write skill active state.
* Called when a skill is invoked via the Skill tool.
* Write support-skill state. No-op for skills with 'none' protection.
*
* @param rawSkillName - The original skill name as invoked, used to distinguish
* OMC built-in skills from project custom skills. See getSkillProtection().
* Preserves the `active_skills` workflow ledger — every write reads the full
* v2 state, updates only the `support_skill` branch, and re-writes both
* copies together via `writeSkillActiveStateCopies()`.
*
* @param rawSkillName - Original skill name as invoked. When provided without
* the `oh-my-claudecode:` prefix, protection returns 'none' to avoid
* confusion with user-defined project skills of the same name (#1581).
*/
export declare function writeSkillActiveState(directory: string, skillName: string, sessionId?: string, rawSkillName?: string): SkillActiveState | null;
/**
* Clear skill active state.
* Called when a skill completes or is cancelled.
* Clear support-skill state while preserving workflow slots.
*/
export declare function clearSkillActiveState(directory: string, sessionId?: string): boolean;
/**
* Check if the skill state is stale (exceeded its TTL).
*/
export declare function isSkillStateStale(state: SkillActiveState): boolean;
/**
* Check skill active state for the Stop hook.
* Returns blocking decision with continuation message.
* Stop-hook integration for support skills.
*
* Called by checkPersistentModes() in the persistent-mode hook.
* Reinforcement increments go through `writeSkillActiveStateCopies()` so the
* workflow-slot ledger is never clobbered by support-skill writes.
*/
export declare function checkSkillActiveState(directory: string, sessionId?: string): {
shouldBlock: boolean;

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/skill-state/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEzE,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;CACtB;AAkFD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAQjG;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAEzF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,gBAAgB,GAAG,IAAI,CAMzB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,EAClB,YAAY,CAAC,EAAE,MAAM,GACpB,gBAAgB,GAAG,IAAI,CAwCzB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAelE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,WAAW,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAiE/D"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/skill-state/index.ts"],"names":[],"mappings":"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"}

553
dist/hooks/skill-state/index.js generated vendored
View File

@@ -1,55 +1,93 @@
/**
* Skill Active State Management
* Skill Active State Management (v2 mixed schema)
*
* Tracks when a skill is actively executing so the persistent-mode Stop hook
* can prevent premature session termination.
* `skill-active-state.json` is a dual-copy workflow ledger:
*
* Skills like plan, external-context, deepinit etc. don't write mode state
* files (ralph-state.json, etc.), so the Stop hook previously had no way to
* know they were running.
* {
* "version": 2,
* "active_skills": { // workflow-slot ledger
* "<canonical workflow skill>": {
* "skill_name": ...,
* "started_at": ...,
* "completed_at": ..., // soft tombstone
* "parent_skill": ..., // lineage for nested runs
* "session_id": ...,
* "mode_state_path": ...,
* "initialized_mode": ...,
* "initialized_state_path": ...,
* "initialized_session_state_path": ...
* }
* },
* "support_skill": { // legacy-compatible branch
* "active": true, "skill_name": "plan", ...
* }
* }
*
* This module provides:
* 1. A protection level registry for all skills (none/light/medium/heavy)
* 2. Read/write/clear functions for skill-active-state.json
* 3. A check function for the Stop hook to determine if blocking is needed
*
* Fix for: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1033
* HARD INVARIANTS:
* 1. `writeSkillActiveStateCopies()` is the only helper allowed to persist
* workflow-slot state. Every workflow-slot write, confirm, tombstone, TTL
* pruning, and hard-clear must update BOTH
* - `.omc/state/skill-active-state.json`
* - `.omc/state/sessions/{sessionId}/skill-active-state.json`
* together through this single helper.
* 2. Support-skill writes go through the same helper so the shared file
* never drops the `active_skills` branch.
* 3. The session copy is authoritative for session-local reads; the root
* copy is authoritative for cross-session aggregation.
*/
import { writeModeState, readModeState, clearModeStateFile } from '../../lib/mode-state-io.js';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { resolveStatePath, resolveSessionStatePath, } from '../../lib/worktree-paths.js';
import { atomicWriteJsonSync } from '../../lib/atomic-write.js';
import { readTrackingState, getStaleAgents } from '../subagent-tracker/index.js';
// ---------------------------------------------------------------------------
// Protection configuration per level
// Constants
// ---------------------------------------------------------------------------
export const SKILL_ACTIVE_STATE_MODE = 'skill-active';
export const SKILL_ACTIVE_STATE_FILE = 'skill-active-state.json';
export const WORKFLOW_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Canonical workflow skills — the only skills that get workflow slots.
* Non-workflow skills keep today's `light/medium/heavy` protection via the
* `support_skill` branch.
*/
export const CANONICAL_WORKFLOW_SKILLS = [
'autopilot',
'ralph',
'team',
'ultrawork',
'ultraqa',
'deep-interview',
'ralplan',
'self-improve',
];
export function isCanonicalWorkflowSkill(skillName) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
return CANONICAL_WORKFLOW_SKILLS.includes(normalized);
}
const PROTECTION_CONFIGS = {
none: { maxReinforcements: 0, staleTtlMs: 0 },
light: { maxReinforcements: 3, staleTtlMs: 5 * 60 * 1000 }, // 5 min
medium: { maxReinforcements: 5, staleTtlMs: 15 * 60 * 1000 }, // 15 min
heavy: { maxReinforcements: 10, staleTtlMs: 30 * 60 * 1000 }, // 30 min
};
// ---------------------------------------------------------------------------
// Skill → protection level mapping
// ---------------------------------------------------------------------------
/**
* Maps each skill name to its protection level.
* Maps each skill name to its support-skill protection level.
*
* - 'none': Already has dedicated mode state (ralph, autopilot, etc.) or is
* instant/read-only (trace, hud, omc-help, etc.)
* - 'light': Quick utility skills
* - 'medium': Review/planning skills that run multiple agents
* - 'heavy': Long-running skills (deepinit, omc-setup)
*
* IMPORTANT: When adding a new OMC skill, register it here with the
* appropriate protection level. Unregistered skills default to 'none'
* (no stop-hook protection) to avoid blocking external plugin skills.
* Workflow skills (autopilot, ralph, ultrawork, team, ultraqa, ralplan,
* deep-interview, self-improve) have dedicated mode state and workflow slots,
* so their support-skill protection is 'none'. They flow through the
* `active_skills` branch instead.
*/
const SKILL_PROTECTION = {
// === Already have mode state → no additional protection ===
// === Canonical workflow skills — bypass support-skill protection; flow through the workflow-slot path ===
autopilot: 'none',
ralph: 'none',
ultrawork: 'none',
team: 'none',
'omc-teams': 'none',
ultraqa: 'none',
ralplan: 'none',
'self-improve': 'none',
cancel: 'none',
// === Instant / read-only → no protection needed ===
trace: 'none',
@@ -65,7 +103,6 @@ const SKILL_PROTECTION = {
// === Medium protection (review/planning, 5 reinforcements) ===
'omc-plan': 'medium',
plan: 'medium',
ralplan: 'none', // Has first-class checkRalplan() enforcement; no skill-active needed
'deep-interview': 'heavy',
review: 'medium',
'external-context': 'medium',
@@ -73,93 +110,378 @@ const SKILL_PROTECTION = {
sciomc: 'medium',
learner: 'medium',
'omc-setup': 'medium',
setup: 'medium', // alias for omc-setup
setup: 'medium',
'mcp-setup': 'medium',
'project-session-manager': 'medium',
psm: 'medium', // alias for project-session-manager
psm: 'medium',
'writer-memory': 'medium',
'ralph-init': 'medium',
release: 'medium',
ccg: 'medium',
// === Heavy protection (long-running, 10 reinforcements) ===
deepinit: 'heavy',
'self-improve': 'heavy',
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Get the protection level for a skill.
*
* Only skills explicitly registered in SKILL_PROTECTION receive stop-hook
* protection. Unregistered skills (including external plugin skills like
* Anthropic's example-skills, document-skills, superpowers, data, etc.)
* default to 'none' so the Stop hook does not block them.
*
* @param skillName - The normalized (prefix-stripped) skill name.
* @param rawSkillName - The original skill name as invoked (e.g., 'oh-my-claudecode:plan'
* or 'plan'). When provided, only skills invoked with the 'oh-my-claudecode:' prefix
* are eligible for protection. This prevents project custom skills (e.g., a user's
* `.claude/skills/plan/`) from being confused with OMC built-in skills of the same name.
* See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1581
*/
export function getSkillProtection(skillName, rawSkillName) {
// When rawSkillName is provided, only apply protection to OMC-prefixed skills.
// Non-prefixed skills are project custom skills or other plugins — no protection.
if (rawSkillName != null && !rawSkillName.toLowerCase().startsWith('oh-my-claudecode:')) {
return 'none';
}
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
return SKILL_PROTECTION[normalized] ?? 'none';
}
/**
* Get the protection config for a skill.
*/
export function getSkillConfig(skillName, rawSkillName) {
return PROTECTION_CONFIGS[getSkillProtection(skillName, rawSkillName)];
}
/**
* Read the current skill active state.
* Returns null if no state exists or state is invalid.
*/
export function readSkillActiveState(directory, sessionId) {
const state = readModeState('skill-active', directory, sessionId);
if (!state || typeof state.active !== 'boolean') {
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
export function emptySkillActiveStateV2() {
return { version: 2, active_skills: {} };
}
function isEmptyV2(state) {
return Object.keys(state.active_skills).length === 0 && !state.support_skill;
}
function readRawFromPath(path) {
if (!existsSync(path))
return null;
try {
return JSON.parse(readFileSync(path, 'utf-8'));
}
catch {
return null;
}
return state;
}
/**
* Write skill active state.
* Called when a skill is invoked via the Skill tool.
* Normalize any raw payload (v1 scalar, v2 mixed, or unknown) into v2. Legacy
* scalar state is folded into `support_skill` so support-skill data is never
* dropped during migration.
*/
function normalizeToV2(raw) {
if (!raw || typeof raw !== 'object') {
return emptySkillActiveStateV2();
}
const obj = raw;
// Strip `_meta` envelope if present (added by atomic writes).
const { _meta: _meta, ...rest } = obj;
void _meta;
const state = rest;
const looksV2 = state.version === 2 || 'active_skills' in state || 'support_skill' in state;
if (looksV2) {
const active_skills = {};
const raw_slots = state.active_skills;
if (raw_slots && typeof raw_slots === 'object' && !Array.isArray(raw_slots)) {
for (const [name, slot] of Object.entries(raw_slots)) {
if (slot && typeof slot === 'object') {
active_skills[name] = slot;
}
}
}
const support_skill = state.support_skill && typeof state.support_skill === 'object'
? state.support_skill
: null;
return { version: 2, active_skills, support_skill };
}
// Legacy scalar shape → fold into support_skill.
if (typeof state.active === 'boolean' && typeof state.skill_name === 'string') {
return {
version: 2,
active_skills: {},
support_skill: state,
};
}
return emptySkillActiveStateV2();
}
// ---------------------------------------------------------------------------
// Pure workflow-slot helpers
// ---------------------------------------------------------------------------
/** Upsert (create or update) a workflow slot on a v2 state. Pure. */
export function upsertWorkflowSkillSlot(state, skillName, slotData = {}) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const existing = state.active_skills[normalized];
const now = new Date().toISOString();
const base = {
skill_name: normalized,
started_at: existing?.started_at ?? now,
completed_at: existing?.completed_at ?? null,
parent_skill: existing?.parent_skill ?? null,
session_id: existing?.session_id ?? '',
mode_state_path: existing?.mode_state_path ?? '',
initialized_mode: existing?.initialized_mode ?? normalized,
initialized_state_path: existing?.initialized_state_path ?? '',
initialized_session_state_path: existing?.initialized_session_state_path ?? '',
};
if (existing?.last_confirmed_at !== undefined) {
base.last_confirmed_at = existing.last_confirmed_at;
}
if (existing?.source !== undefined) {
base.source = existing.source;
}
const next = {
...base,
...slotData,
skill_name: normalized,
};
return {
...state,
active_skills: { ...state.active_skills, [normalized]: next },
};
}
/**
* Soft tombstone: set `completed_at` on an existing slot. Slot is retained
* until the TTL pruner removes it. Returns state unchanged when the slot is
* absent (idempotent).
*/
export function markWorkflowSkillCompleted(state, skillName, now = new Date().toISOString()) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const existing = state.active_skills[normalized];
if (!existing)
return state;
const updated = { ...existing, completed_at: now };
return {
...state,
active_skills: { ...state.active_skills, [normalized]: updated },
};
}
/** Hard-clear: remove a slot entirely (for explicit cancel). Pure. */
export function clearWorkflowSkillSlot(state, skillName) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
if (!(normalized in state.active_skills))
return state;
const next = { ...state.active_skills };
delete next[normalized];
return { ...state, active_skills: next };
}
/**
* TTL prune: remove tombstoned slots whose `completed_at + ttlMs < now`.
* Called on UserPromptSubmit. Pure.
*/
export function pruneExpiredWorkflowSkillTombstones(state, ttlMs = WORKFLOW_TOMBSTONE_TTL_MS, now = Date.now()) {
const next = {};
let changed = false;
for (const [name, slot] of Object.entries(state.active_skills)) {
if (!slot.completed_at) {
next[name] = slot;
continue;
}
const tombstonedAt = new Date(slot.completed_at).getTime();
if (!Number.isFinite(tombstonedAt)) {
// Malformed timestamp — keep defensively rather than silently drop.
next[name] = slot;
continue;
}
if (now - tombstonedAt < ttlMs) {
next[name] = slot;
}
else {
changed = true;
}
}
return changed ? { ...state, active_skills: next } : state;
}
/**
* Resolve the authoritative workflow slot for stop-hook and downstream
* consumers.
*
* @param rawSkillName - The original skill name as invoked, used to distinguish
* OMC built-in skills from project custom skills. See getSkillProtection().
* Rule: among live (non-tombstoned) slots, prefer those whose parent lineage
* is absent or itself tombstoned (roots of the live chain). Among those,
* return the newest by `started_at`. In nested `autopilot → ralph` flows this
* returns `autopilot` while ralph is still live beneath it, so stop-hook
* enforcement keeps reinforcing the outer loop.
*/
export function resolveAuthoritativeWorkflowSkill(state) {
const live = Object.values(state.active_skills).filter((s) => !s.completed_at);
if (live.length === 0)
return null;
const isLiveAncestor = (name) => {
if (!name)
return false;
const parent = state.active_skills[name];
return !!parent && !parent.completed_at;
};
const roots = live.filter((s) => !isLiveAncestor(s.parent_skill ?? null));
const pool = roots.length > 0 ? roots : live;
pool.sort((a, b) => {
const bt = new Date(b.started_at).getTime() || 0;
const at = new Date(a.started_at).getTime() || 0;
return bt - at;
});
return pool[0] ?? null;
}
/**
* Pure query: is the workflow slot for `skillName` live (non-tombstoned)?
* Returns false when no slot exists at all, so callers can distinguish
* "no ledger entry" from "tombstoned" via `isWorkflowSkillTombstoned`.
*/
export function isWorkflowSkillLive(state, skillName) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const slot = state.active_skills[normalized];
return !!slot && !slot.completed_at;
}
/**
* Pure query: is the slot tombstoned (has `completed_at`) and not yet expired?
* Used by stop enforcement to suppress noisy re-handoff on completed workflows
* until TTL pruning removes the slot or a fresh invocation reactivates it.
*/
export function isWorkflowSkillTombstoned(state, skillName, ttlMs = WORKFLOW_TOMBSTONE_TTL_MS, now = Date.now()) {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const slot = state.active_skills[normalized];
if (!slot || !slot.completed_at)
return false;
const tombstonedAt = new Date(slot.completed_at).getTime();
if (!Number.isFinite(tombstonedAt))
return true;
return now - tombstonedAt < ttlMs;
}
// ---------------------------------------------------------------------------
// Read / Write I/O
// ---------------------------------------------------------------------------
/**
* Read the v2 mixed-schema workflow ledger, normalizing legacy scalar state
* into `support_skill` without dropping support-skill data.
*
* When `sessionId` is provided, the session copy is authoritative for
* session-local reads. No fall-through to the root copy, to prevent
* cross-session leakage. When no session copy exists for the session, the
* ledger is treated as empty for that session's local reads.
*
* When `sessionId` is omitted (legacy/global path), the root copy is read.
*
* Logs a reconciliation warning when the session copy diverges from the root
* for slots belonging to the same session. The next mutation through
* `writeSkillActiveStateCopies()` re-synchronizes both copies.
*/
export function readSkillActiveStateNormalized(directory, sessionId) {
const rootPath = resolveStatePath('skill-active', directory);
const sessionPath = sessionId
? resolveSessionStatePath('skill-active', sessionId, directory)
: null;
const sessionExists = !!(sessionPath && existsSync(sessionPath));
const rootExists = existsSync(rootPath);
const sessionV2 = sessionExists ? normalizeToV2(readRawFromPath(sessionPath)) : null;
const rootV2 = rootExists ? normalizeToV2(readRawFromPath(rootPath)) : null;
// Divergence detection — best-effort; logged but non-fatal.
if (sessionV2 && rootV2 && sessionId) {
for (const [name, sessSlot] of Object.entries(sessionV2.active_skills)) {
const rootSlot = rootV2.active_skills[name];
if (!rootSlot)
continue;
if (sessSlot.session_id !== sessionId)
continue;
if (JSON.stringify(sessSlot) !== JSON.stringify(rootSlot)) {
// Non-fatal — next writeSkillActiveStateCopies() call will re-sync.
console.warn(`[skill-active] copy drift detected for slot "${name}" in session ${sessionId}; ` +
'next mutation will reconcile via writeSkillActiveStateCopies().');
break;
}
}
}
// Session copy authoritative for session-local reads.
if (sessionV2)
return sessionV2;
// sessionId provided but no session copy — do NOT fall back to root to
// prevent cross-session state leakage (#456).
if (sessionId)
return emptySkillActiveStateV2();
// Legacy/global path: read root.
return rootV2 ?? emptySkillActiveStateV2();
}
/**
* THE ONLY HELPER allowed to persist workflow-slot state.
*
* Writes BOTH root `.omc/state/skill-active-state.json` AND session
* `.omc/state/sessions/{sessionId}/skill-active-state.json` together. When a
* resolved state is empty (no slots, no support_skill), the corresponding
* file is removed instead — the absence of a file is the canonical empty
* state.
*
* @returns true when all writes / deletes succeeded, false otherwise.
*/
export function writeSkillActiveStateCopies(directory, nextState, sessionId, options) {
const rootPath = resolveStatePath('skill-active', directory);
const sessionPath = sessionId
? resolveSessionStatePath('skill-active', sessionId, directory)
: null;
// Root defaults to the same payload as session. Explicit `null` deletes root.
const rootState = options?.rootState === undefined ? nextState : options.rootState;
const writeOrRemove = (filePath, payload) => {
const shouldRemove = payload === null || isEmptyV2(payload);
if (shouldRemove) {
if (!existsSync(filePath))
return true;
try {
unlinkSync(filePath);
return true;
}
catch {
return false;
}
}
try {
const envelope = {
...payload,
version: 2,
_meta: {
written_at: new Date().toISOString(),
mode: SKILL_ACTIVE_STATE_MODE,
...(sessionId ? { sessionId } : {}),
},
};
atomicWriteJsonSync(filePath, envelope);
return true;
}
catch {
return false;
}
};
let ok = writeOrRemove(rootPath, rootState);
if (sessionPath) {
ok = writeOrRemove(sessionPath, nextState) && ok;
}
return ok;
}
// ---------------------------------------------------------------------------
// Legacy-compatible support-skill API (operates on the `support_skill` branch)
// ---------------------------------------------------------------------------
/**
* Read the support-skill state as a legacy scalar `SkillActiveState`.
*
* Returns null when no support_skill entry is present in the v2 ledger.
* Workflow slots are intentionally NOT exposed through this function —
* downstream workflow consumers should call `readSkillActiveStateNormalized()`
* and `resolveAuthoritativeWorkflowSkill()` instead.
*/
export function readSkillActiveState(directory, sessionId) {
const v2 = readSkillActiveStateNormalized(directory, sessionId);
const support = v2.support_skill;
if (!support || typeof support.active !== 'boolean')
return null;
return support;
}
/**
* Write support-skill state. No-op for skills with 'none' protection.
*
* Preserves the `active_skills` workflow ledger — every write reads the full
* v2 state, updates only the `support_skill` branch, and re-writes both
* copies together via `writeSkillActiveStateCopies()`.
*
* @param rawSkillName - Original skill name as invoked. When provided without
* the `oh-my-claudecode:` prefix, protection returns 'none' to avoid
* confusion with user-defined project skills of the same name (#1581).
*/
export function writeSkillActiveState(directory, skillName, sessionId, rawSkillName) {
const protection = getSkillProtection(skillName, rawSkillName);
// Skills with 'none' protection don't need state tracking
if (protection === 'none') {
if (protection === 'none')
return null;
}
const config = PROTECTION_CONFIGS[protection];
const now = new Date().toISOString();
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
// Nesting guard: when a skill (e.g. omc-setup) invokes a child skill
// (e.g. mcp-setup), the child must not overwrite the parent's active state.
// If a DIFFERENT skill is already active in this session, skip writing —
// the parent's stop-hook protection already covers the session.
// If the SAME skill is re-invoked, allow the overwrite (idempotent refresh).
//
// NOTE: This read-check-write sequence has a TOCTOU race condition
// (non-atomic), but this is acceptable because Claude Code sessions are
// single-threaded — only one tool call executes at a time within a session.
const existingState = readSkillActiveState(directory, sessionId);
if (existingState && existingState.active && existingState.skill_name !== normalized) {
// A different skill already owns the active state — do not overwrite.
const existingV2 = readSkillActiveStateNormalized(directory, sessionId);
const existing = existingV2.support_skill;
// Nesting guard: a DIFFERENT support skill already owns the slot — skip.
// Same skill re-invocation is allowed (idempotent refresh).
if (existing && existing.active && existing.skill_name !== normalized) {
return null;
}
const state = {
const support = {
active: true,
skill_name: normalized,
session_id: sessionId,
@@ -169,19 +491,18 @@ export function writeSkillActiveState(directory, skillName, sessionId, rawSkillN
max_reinforcements: config.maxReinforcements,
stale_ttl_ms: config.staleTtlMs,
};
const success = writeModeState('skill-active', state, directory, sessionId);
return success ? state : null;
const nextV2 = { ...existingV2, support_skill: support };
const ok = writeSkillActiveStateCopies(directory, nextV2, sessionId);
return ok ? support : null;
}
/**
* Clear skill active state.
* Called when a skill completes or is cancelled.
* Clear support-skill state while preserving workflow slots.
*/
export function clearSkillActiveState(directory, sessionId) {
return clearModeStateFile('skill-active', directory, sessionId);
const existingV2 = readSkillActiveStateNormalized(directory, sessionId);
const nextV2 = { ...existingV2, support_skill: null };
return writeSkillActiveStateCopies(directory, nextV2, sessionId);
}
/**
* Check if the skill state is stale (exceeded its TTL).
*/
export function isSkillStateStale(state) {
if (!state.active)
return true;
@@ -198,10 +519,10 @@ export function isSkillStateStale(state) {
return age > (state.stale_ttl_ms || 5 * 60 * 1000);
}
/**
* Check skill active state for the Stop hook.
* Returns blocking decision with continuation message.
* Stop-hook integration for support skills.
*
* Called by checkPersistentModes() in the persistent-mode hook.
* Reinforcement increments go through `writeSkillActiveStateCopies()` so the
* workflow-slot ledger is never clobbered by support-skill writes.
*/
export function checkSkillActiveState(directory, sessionId) {
const state = readSkillActiveState(directory, sessionId);
@@ -223,39 +544,39 @@ export function checkSkillActiveState(directory, sessionId) {
return { shouldBlock: false, message: '' };
}
// Orchestrators are allowed to go idle while delegated work is still active.
// Do not consume a reinforcement here; the skill is still active and should
// resume enforcement only after the running subagents finish.
// Read tracking state and exclude stale agents (>5 min without updates)
// to prevent phantom "running" entries from blocking enforcement.
// Uses read-only filtering instead of cleanupStaleAgents() to avoid
// destructively marking legitimate long-running agents as failed.
const trackingState = readTrackingState(directory);
const staleIds = new Set(getStaleAgents(trackingState).map(a => a.agent_id));
const nonStaleRunning = trackingState.agents.filter(a => a.status === 'running' && !staleIds.has(a.agent_id));
const staleIds = new Set(getStaleAgents(trackingState).map((a) => a.agent_id));
const nonStaleRunning = trackingState.agents.filter((a) => a.status === 'running' && !staleIds.has(a.agent_id));
if (nonStaleRunning.length > 0) {
// Reset reinforcement counter so accumulations during brief idle gaps
// don't cause premature skill-active clearance.
// Mirrors ralplan's writeStopBreaker(0) at persistent-mode/index.ts:984.
if (state.reinforcement_count > 0) {
state.reinforcement_count = 0;
state.last_checked_at = new Date().toISOString();
writeModeState('skill-active', state, directory, sessionId);
const resetSupport = {
...state,
reinforcement_count: 0,
last_checked_at: new Date().toISOString(),
};
const v2 = readSkillActiveStateNormalized(directory, sessionId);
writeSkillActiveStateCopies(directory, { ...v2, support_skill: resetSupport }, sessionId);
}
return { shouldBlock: false, message: '', skillName: state.skill_name };
}
// Block the stop and increment reinforcement count
state.reinforcement_count += 1;
state.last_checked_at = new Date().toISOString();
const written = writeModeState('skill-active', state, directory, sessionId);
if (!written) {
// If we can't write, don't block
// Block the stop and increment reinforcement count.
const incremented = {
...state,
reinforcement_count: state.reinforcement_count + 1,
last_checked_at: new Date().toISOString(),
};
const v2 = readSkillActiveStateNormalized(directory, sessionId);
const ok = writeSkillActiveStateCopies(directory, { ...v2, support_skill: incremented }, sessionId);
if (!ok) {
return { shouldBlock: false, message: '' };
}
const message = `[SKILL ACTIVE: ${state.skill_name}] The "${state.skill_name}" skill is still executing (reinforcement ${state.reinforcement_count}/${state.max_reinforcements}). Continue working on the skill's instructions. Do not stop until the skill completes its workflow.`;
const message = `[SKILL ACTIVE: ${incremented.skill_name}] The "${incremented.skill_name}" skill is still executing ` +
`(reinforcement ${incremented.reinforcement_count}/${incremented.max_reinforcements}). ` +
`Continue working on the skill's instructions. Do not stop until the skill completes its workflow.`;
return {
shouldBlock: true,
message,
skillName: state.skill_name,
skillName: incremented.skill_name,
};
}
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -148,14 +148,14 @@ World`);
it('should return high variant for claude-sonnet-4-6', () => {
expect(getHighVariant('claude-sonnet-4-6')).toBe('claude-sonnet-4-6-high');
});
it('should return high variant for claude-opus-4-6', () => {
expect(getHighVariant('claude-opus-4-6')).toBe('claude-opus-4-6-high');
it('should return high variant for claude-opus-4-7', () => {
expect(getHighVariant('claude-opus-4-7')).toBe('claude-opus-4-7-high');
});
it('should return high variant for claude-3-5-sonnet', () => {
expect(getHighVariant('claude-3-5-sonnet')).toBe('claude-sonnet-4-6-high');
});
it('should return high variant for claude-3-opus', () => {
expect(getHighVariant('claude-3-opus')).toBe('claude-opus-4-6-high');
expect(getHighVariant('claude-3-opus')).toBe('claude-opus-4-7-high');
});
it('should handle version with dot notation', () => {
expect(getHighVariant('claude-sonnet-4.5')).toBe('claude-sonnet-4-6-high');

4
dist/hud/elements/model.js generated vendored
View File

@@ -7,7 +7,7 @@ import { cyan } from '../colors.js';
import { truncateToWidth } from '../../utils/string-width.js';
/**
* Extract version from a model ID string.
* E.g., 'claude-opus-4-6-20260205' -> '4.6'
* E.g., 'claude-opus-4-7-20260416' -> '4.7'
* 'claude-sonnet-4-6-20260217' -> '4.6'
* 'claude-haiku-4-5-20251001' -> '4.5'
*/
@@ -16,7 +16,7 @@ function extractVersion(modelId) {
const idMatch = modelId.match(/(?:opus|sonnet|haiku)-(\d+)-(\d+)/i);
if (idMatch)
return `${idMatch[1]}.${idMatch[2]}`;
// Match display name patterns like "Sonnet 4.5", "Opus 4.6"
// Match display name patterns like "Sonnet 4.5", "Opus 4.7"
const displayMatch = modelId.match(/(?:opus|sonnet|haiku)\s+(\d+(?:\.\d+)?)/i);
if (displayMatch)
return displayMatch[1];

4
dist/hud/types.d.ts generated vendored
View File

@@ -318,8 +318,8 @@ export type CwdFormat = 'relative' | 'absolute' | 'folder';
/**
* Model name format options:
* - short: 'Opus', 'Sonnet', 'Haiku'
* - versioned: 'Opus 4.6', 'Sonnet 4.5', 'Haiku 4.5'
* - full: raw model ID like 'claude-opus-4-6-20260205'
* - versioned: 'Opus 4.7', 'Sonnet 4.5', 'Haiku 4.5'
* - full: raw model ID like 'claude-opus-4-7-20260416'
*/
export type ModelFormat = 'short' | 'versioned' | 'full';
export type CallCountsFormat = 'auto' | 'emoji' | 'ascii';

View File

@@ -145,6 +145,38 @@ describe('unified MCP registry sync', () => {
expect(codexConfig).toContain('url = "https://lab.example.com/mcp"');
expect(codexConfig).toContain('startup_timeout_sec = 30');
});
it('reproduces issue #2679: sync strips remote entry type during round-trip', () => {
const settings = {
mcpServers: {
mySseServer: {
url: 'http://localhost:11235/mcp/sse',
type: 'sse',
},
},
};
const { settings: syncedSettings, result } = syncUnifiedMcpRegistryTargets(settings);
expect(result.bootstrappedFromClaude).toBe(true);
expect(result.serverNames).toEqual(['mySseServer']);
expect(syncedSettings).toEqual({});
expect(JSON.parse(readFileSync(getUnifiedMcpRegistryPath(), 'utf-8'))).toEqual({
mySseServer: {
url: 'http://localhost:11235/mcp/sse',
type: 'sse',
},
});
expect(JSON.parse(readFileSync(getClaudeMcpConfigPath(), 'utf-8'))).toEqual({
mcpServers: {
mySseServer: {
url: 'http://localhost:11235/mcp/sse',
type: 'sse',
},
},
});
const codexConfig = readFileSync(getCodexConfigPath(), 'utf-8');
expect(codexConfig).toContain('[mcp_servers.mySseServer]');
expect(codexConfig).toContain('url = "http://localhost:11235/mcp/sse"');
expect(codexConfig).toContain('type = "sse"');
});
it('preserves explicit launcher timeouts and leaves custom MCP servers untouched', () => {
const settings = {
mcpServers: {
@@ -347,6 +379,30 @@ describe('unified MCP registry sync', () => {
},
});
});
it('respects explicit removal from ~/.claude.json when legacy settings still contain a stale copy', () => {
writeFileSync(getUnifiedMcpRegistryPath(), JSON.stringify({
gitnexus: { command: 'gitnexus', args: ['mcp'] },
}, null, 2));
writeFileSync(getClaudeMcpConfigPath(), JSON.stringify({
mcpServers: {
customLocal: { command: 'custom-local', args: ['serve'] },
},
}, null, 2));
const { settings, result } = syncUnifiedMcpRegistryTargets({
theme: 'dark',
mcpServers: {
gitnexus: { command: 'stale-gitnexus', args: ['legacy'] },
},
});
expect(settings).toEqual({ theme: 'dark' });
expect(result.bootstrappedFromClaude).toBe(false);
expect(JSON.parse(readFileSync(getClaudeMcpConfigPath(), 'utf-8'))).toEqual({
mcpServers: {
customLocal: { command: 'custom-local', args: ['serve'] },
gitnexus: { command: 'gitnexus', args: ['mcp'] },
},
});
});
it('detects mismatched URL-based remote MCP definitions during doctor inspection', () => {
writeFileSync(getUnifiedMcpRegistryPath(), JSON.stringify({
remoteOmc: { url: 'https://lab.example.com/mcp', timeout: 30 },

File diff suppressed because one or more lines are too long

2
dist/installer/__tests__/plugin-cache-sync.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=plugin-cache-sync.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"plugin-cache-sync.test.d.ts","sourceRoot":"","sources":["../../../src/installer/__tests__/plugin-cache-sync.test.ts"],"names":[],"mappings":""}

117
dist/installer/__tests__/plugin-cache-sync.test.js generated vendored Normal file
View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
const ORIG_ENV = { ...process.env };
function writeFile(path, content) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}
function writePayloadTree(root, version = '9.9.9-test') {
mkdirSync(root, { recursive: true });
writeFile(join(root, 'dist', 'lib', 'worktree-paths.js'), 'export const test = true;\n');
writeFile(join(root, 'bridge', 'cli.cjs'), 'console.log("bridge");\n');
writeFile(join(root, 'hooks', 'hooks.json'), '{}\n');
writeFile(join(root, 'scripts', 'run.cjs'), 'console.log("run");\n');
writeFile(join(root, 'skills', 'plan', 'SKILL.md'), '# plan\n');
writeFile(join(root, 'agents', 'executor.md'), '# executor\n');
writeFile(join(root, 'templates', 'deliverables.json'), '{}\n');
writeFile(join(root, 'docs', 'CLAUDE.md'), '# docs\n');
writeFile(join(root, '.claude-plugin', 'plugin.json'), '{"name":"oh-my-claudecode"}\n');
writeFile(join(root, '.mcp.json'), '{}\n');
writeFile(join(root, 'README.md'), '# readme\n');
writeFile(join(root, 'LICENSE'), 'MIT\n');
writeFile(join(root, 'package.json'), JSON.stringify({ name: 'oh-my-claude-sisyphus', version }, null, 2));
}
async function freshInstaller() {
vi.resetModules();
return await import('../index.js');
}
describe('syncInstalledPluginPayload', () => {
let tempRoot;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'omc-plugin-cache-sync-'));
process.env.CLAUDE_CONFIG_DIR = join(tempRoot, '.claude');
delete process.env.CLAUDE_PLUGIN_ROOT;
delete process.env.OMC_PLUGIN_ROOT;
});
afterEach(() => {
vi.restoreAllMocks();
for (const key of Object.keys(process.env)) {
if (!(key in ORIG_ENV))
delete process.env[key];
}
Object.assign(process.env, ORIG_ENV);
rmSync(tempRoot, { recursive: true, force: true });
});
it('repairs incomplete cache installs from the known marketplace source instead of reusing the installed root', async () => {
const configDir = process.env.CLAUDE_CONFIG_DIR;
const cacheRoot = join(configDir, 'plugins', 'cache', 'omc', 'oh-my-claudecode', '4.12.0');
const sourceRoot = join(tempRoot, 'marketplace-source');
writePayloadTree(sourceRoot);
mkdirSync(join(cacheRoot, 'agents'), { recursive: true });
writeFileSync(join(cacheRoot, 'agents', 'executor.md'), '# stale executor\n');
mkdirSync(join(configDir, 'plugins'), { recursive: true });
writeFileSync(join(configDir, 'plugins', 'installed_plugins.json'), JSON.stringify({
version: 2,
plugins: {
'oh-my-claudecode@omc': [{ installPath: cacheRoot, version: '4.12.0' }],
},
}, null, 2));
writeFileSync(join(configDir, 'plugins', 'known_marketplaces.json'), JSON.stringify({
omc: {
installLocation: sourceRoot,
source: { source: 'directory', path: sourceRoot },
},
}, null, 2));
const installer = await freshInstaller();
const result = installer.syncInstalledPluginPayload();
expect(result.synced).toBe(true);
expect(result.errors).toEqual([]);
expect(result.sourceRoot).toBe(sourceRoot);
expect(result.targetRoots).toEqual([cacheRoot]);
expect(existsSync(join(cacheRoot, 'package.json'))).toBe(true);
expect(existsSync(join(cacheRoot, 'skills', 'plan', 'SKILL.md'))).toBe(true);
expect(existsSync(join(cacheRoot, 'hooks', 'hooks.json'))).toBe(true);
expect(existsSync(join(cacheRoot, 'scripts', 'run.cjs'))).toBe(true);
expect(JSON.parse(readFileSync(join(cacheRoot, 'package.json'), 'utf-8')).version).toBe('9.9.9-test');
});
it('repairs incomplete cache installs during setup before plugin-provided file detection runs', async () => {
const configDir = process.env.CLAUDE_CONFIG_DIR;
const cacheRoot = join(configDir, 'plugins', 'cache', 'omc', 'oh-my-claudecode', '4.12.0');
const sourceRoot = join(tempRoot, 'marketplace-source-install');
writePayloadTree(sourceRoot, '4.12.0');
mkdirSync(join(cacheRoot, 'agents'), { recursive: true });
writeFileSync(join(cacheRoot, 'agents', 'executor.md'), '# stale executor\n');
mkdirSync(join(configDir, 'plugins'), { recursive: true });
writeFileSync(join(configDir, 'plugins', 'installed_plugins.json'), JSON.stringify({
version: 2,
plugins: {
'oh-my-claudecode@omc': [{ installPath: cacheRoot, version: '4.12.0' }],
},
}, null, 2));
writeFileSync(join(configDir, 'plugins', 'known_marketplaces.json'), JSON.stringify({
omc: {
installLocation: sourceRoot,
source: { source: 'directory', path: sourceRoot },
},
}, null, 2));
writeFileSync(join(configDir, 'settings.json'), JSON.stringify({ enabledPlugins: ['oh-my-claudecode@omc'] }, null, 2));
const installer = await freshInstaller();
const result = installer.install({
skipClaudeCheck: true,
skipHud: true,
});
expect(result.success).toBe(true);
expect(result.installedAgents).toEqual([]);
expect(result.installedSkills).toEqual([]);
expect(installer.hasPluginProvidedAgentFiles()).toBe(true);
expect(installer.hasPluginProvidedSkillFiles()).toBe(true);
expect(installer.hasPluginProvidedHookFiles()).toBe(true);
expect(existsSync(join(cacheRoot, 'package.json'))).toBe(true);
expect(existsSync(join(cacheRoot, 'skills', 'plan', 'SKILL.md'))).toBe(true);
expect(existsSync(join(cacheRoot, 'hooks', 'hooks.json'))).toBe(true);
expect(existsSync(join(cacheRoot, 'scripts', 'run.cjs'))).toBe(true);
});
});
//# sourceMappingURL=plugin-cache-sync.test.js.map

File diff suppressed because one or more lines are too long

10
dist/installer/index.d.ts generated vendored
View File

@@ -166,6 +166,16 @@ export declare function cleanupStaleSkills(log: (msg: string) => void): string[]
*/
export declare function prunePluginDuplicateSkills(log: (msg: string) => void): string[];
export declare function getInstalledOmcPluginRoots(): string[];
export declare function copyPluginSyncPayload(sourceRoot: string, targetRoots: string[]): {
synced: boolean;
errors: string[];
};
export declare function syncInstalledPluginPayload(): {
synced: boolean;
errors: string[];
sourceRoot: string | null;
targetRoots: string[];
};
/**
* Detect whether an installed Claude Code plugin already provides OMC agent
* markdown files, so the legacy ~/.claude/agents copy can be skipped.

2
dist/installer/index.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/installer/index.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/installer/index.ts"],"names":[],"mappings":"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"}

164
dist/installer/index.js generated vendored
View File

@@ -800,6 +800,157 @@ export function getInstalledOmcPluginRoots() {
}
return Array.from(pluginRoots);
}
const PLUGIN_SYNC_PAYLOAD = [
'dist',
'bridge',
'hooks',
'scripts',
'skills',
'agents',
'templates',
'docs',
'.claude-plugin',
'.mcp.json',
'README.md',
'LICENSE',
'package.json',
];
function countPluginSyncPayloadEntries(root) {
let score = 0;
for (const entry of PLUGIN_SYNC_PAYLOAD) {
if (existsSync(join(root, entry))) {
score += 1;
}
}
return score;
}
function getKnownMarketplaceInstallRoots() {
const knownMarketplacesPath = join(CLAUDE_CONFIG_DIR, 'plugins', 'known_marketplaces.json');
if (!existsSync(knownMarketplacesPath)) {
return [];
}
try {
const raw = JSON.parse(readFileSync(knownMarketplacesPath, 'utf-8'));
const roots = new Set();
for (const [marketplaceId, entry] of Object.entries(raw)) {
const isOmcMarketplace = marketplaceId.toLowerCase().includes('omc')
|| marketplaceId.toLowerCase().includes('oh-my-claudecode');
if (!isOmcMarketplace) {
continue;
}
if (typeof entry?.installLocation === 'string' && entry.installLocation.trim().length > 0) {
roots.add(entry.installLocation.trim());
}
if (typeof entry?.source?.path === 'string' && entry.source.path.trim().length > 0) {
roots.add(entry.source.path.trim());
}
}
return Array.from(roots);
}
catch {
return [];
}
}
function getGlobalInstalledPackageRoot() {
try {
const npmRoot = String(execSync('npm root -g', {
encoding: 'utf-8',
stdio: 'pipe',
timeout: 10000,
...(process.platform === 'win32' ? { windowsHide: true } : {}),
}) ?? '').trim();
if (!npmRoot) {
return null;
}
const globalPackageRoot = join(npmRoot, 'oh-my-claude-sisyphus');
return existsSync(globalPackageRoot) ? globalPackageRoot : null;
}
catch {
return null;
}
}
function isCacheInstalledPluginRoot(root) {
const normalizedRoot = normalizePath(root);
const cacheBase = normalizePath(join(CLAUDE_CONFIG_DIR, 'plugins', 'cache'));
return normalizedRoot === cacheBase || normalizedRoot.startsWith(`${cacheBase}/`);
}
function resolveBestPluginSyncSource(targetRoots) {
const excludedRoots = new Set(targetRoots.map(normalizePath));
const seen = new Set();
const globalPackageRoot = getGlobalInstalledPackageRoot();
const candidates = [
...getKnownMarketplaceInstallRoots(),
...(globalPackageRoot ? [globalPackageRoot] : []),
getRuntimePackageRoot(),
];
let bestRoot = null;
let bestScore = -1;
let bestOrder = Number.POSITIVE_INFINITY;
for (const [order, candidate] of candidates.entries()) {
const normalizedCandidate = normalizePath(candidate);
if (seen.has(normalizedCandidate) || excludedRoots.has(normalizedCandidate) || !existsSync(candidate)) {
continue;
}
seen.add(normalizedCandidate);
const score = countPluginSyncPayloadEntries(candidate);
if (score === 0) {
continue;
}
if (score > bestScore || (score === bestScore && order < bestOrder)) {
bestRoot = candidate;
bestScore = score;
bestOrder = order;
}
}
return bestRoot;
}
export function copyPluginSyncPayload(sourceRoot, targetRoots) {
if (targetRoots.length === 0) {
return { synced: false, errors: [] };
}
let synced = false;
const errors = [];
for (const targetRoot of targetRoots) {
let copiedToTarget = false;
for (const entry of PLUGIN_SYNC_PAYLOAD) {
const sourcePath = join(sourceRoot, entry);
if (!existsSync(sourcePath)) {
continue;
}
try {
cpSync(sourcePath, join(targetRoot, entry), {
recursive: true,
force: true,
});
copiedToTarget = true;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync ${entry} to ${targetRoot}: ${message}`);
}
}
synced = synced || copiedToTarget;
}
return { synced, errors };
}
export function syncInstalledPluginPayload() {
const targetRoots = getInstalledOmcPluginRoots()
.filter(root => existsSync(root) && isCacheInstalledPluginRoot(root));
if (targetRoots.length === 0) {
return { synced: false, errors: [], sourceRoot: null, targetRoots: [] };
}
const sourceRoot = resolveBestPluginSyncSource(targetRoots);
if (!sourceRoot) {
return {
synced: false,
errors: ['Unable to find a complete OMC package source to repair installed plugin roots'],
sourceRoot: null,
targetRoots,
};
}
const result = copyPluginSyncPayload(sourceRoot, targetRoots);
return { ...result, sourceRoot, targetRoots };
}
/**
* Detect whether an installed Claude Code plugin already provides OMC agent
* markdown files, so the legacy ~/.claude/agents copy can be skipped.
@@ -1147,6 +1298,19 @@ export function install(options = {}) {
// Check if running as a plugin
const runningAsPlugin = isRunningAsPlugin();
const projectScoped = isProjectScopedPlugin();
const pluginPayloadSync = syncInstalledPluginPayload();
if (pluginPayloadSync.errors.length > 0) {
for (const error of pluginPayloadSync.errors) {
log(`Plugin cache sync warning: ${error}`);
}
}
if (pluginPayloadSync.synced) {
const targetSummary = pluginPayloadSync.targetRoots.length > 0
? pluginPayloadSync.targetRoots.join(', ')
: 'installed plugin roots';
const sourceSummary = pluginPayloadSync.sourceRoot ?? 'unknown source';
log(`Repaired installed OMC plugin payload from ${sourceSummary} -> ${targetSummary}`);
}
const pluginProvidesAgentFiles = hasPluginProvidedAgentFiles();
const pluginProvidesSkillFiles = hasPluginProvidedSkillFiles();
const pluginProvidesHookFiles = hasPluginProvidedHookFiles();

2
dist/installer/index.js.map generated vendored

File diff suppressed because one or more lines are too long

1
dist/installer/mcp-registry.d.ts generated vendored
View File

@@ -3,6 +3,7 @@ export interface UnifiedMcpRegistryEntry {
args?: string[];
env?: Record<string, string>;
url?: string;
type?: string;
timeout?: number;
}
export type UnifiedMcpRegistry = Record<string, UnifiedMcpRegistryEntry>;

View File

@@ -1 +1 @@
{"version":3,"file":"mcp-registry.d.ts","sourceRoot":"","sources":["../../src/installer/mcp-registry.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;AAEzE,MAAM,WAAW,4BAA4B;IAC3C,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAMD,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAkBD,wBAAgB,sBAAsB,IAAI,MAAM,CAM/C;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAG3C;AAiGD,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,kBAAkB,CAE9F;AAED,wBAAgB,0BAA0B,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG;IAAE,QAAQ,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CA6B5H;AAiFD,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CASzD;AA6HD,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAQ/E;AAED,wBAAgB,mBAAmB,CAAC,eAAe,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAWhI;AAkED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,MAAM,EAAE,4BAA4B,CAAA;CAAE,CAgD7E;AAiBD,wBAAgB,6BAA6B,IAAI,wBAAwB,CAyDxE"}
{"version":3,"file":"mcp-registry.d.ts","sourceRoot":"","sources":["../../src/installer/mcp-registry.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;AAEzE,MAAM,WAAW,4BAA4B;IAC3C,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAMD,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAkBD,wBAAgB,sBAAsB,IAAI,MAAM,CAM/C;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAG3C;AAqGD,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,kBAAkB,CAE9F;AAED,wBAAgB,0BAA0B,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG;IAAE,QAAQ,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CA6B5H;AAiFD,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CASzD;AAgID,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAQ/E;AAED,wBAAgB,mBAAmB,CAAC,eAAe,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAWhI;AAqED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,MAAM,EAAE,4BAA4B,CAAA;CAAE,CAgD7E;AAiBD,wBAAgB,6BAA6B,IAAI,wBAAwB,CAyDxE"}

12
dist/installer/mcp-registry.js generated vendored
View File

@@ -72,6 +72,9 @@ function normalizeRegistryEntry(value) {
const url = typeof raw.url === 'string' && raw.url.trim().length > 0
? raw.url.trim()
: undefined;
const type = typeof raw.type === 'string' && raw.type.trim().length > 0
? raw.type.trim()
: undefined;
if (!command && !url) {
return null;
}
@@ -88,6 +91,7 @@ function normalizeRegistryEntry(value) {
...(args.length > 0 ? { args } : {}),
...(env && Object.keys(env).length > 0 ? { env } : {}),
...(url ? { url } : {}),
...(type ? { type } : {}),
...(effectiveTimeout ? { timeout: effectiveTimeout } : {}),
};
}
@@ -294,6 +298,9 @@ function renderCodexServerBlock(name, entry) {
if (entry.url) {
lines.push(`url = ${renderTomlString(entry.url)}`);
}
if (entry.type) {
lines.push(`type = ${renderTomlString(entry.type)}`);
}
if (entry.env && Object.keys(entry.env).length > 0) {
lines.push(`env = ${renderTomlEnvTable(entry.env)}`);
}
@@ -376,6 +383,11 @@ function parseCodexMcpRegistryEntries(content) {
if (parsed)
currentEntry.url = parsed;
}
else if (key === 'type') {
const parsed = parseTomlQuotedString(value);
if (parsed)
currentEntry.type = parsed;
}
else if (key === 'env') {
const parsed = parseTomlEnvTable(value);
if (parsed)

2
dist/installer/mcp-registry.js.map generated vendored

File diff suppressed because one or more lines are too long

2
dist/lib/mode-names.d.ts generated vendored
View File

@@ -13,6 +13,8 @@ export declare const MODE_NAMES: {
readonly ULTRAWORK: "ultrawork";
readonly ULTRAQA: "ultraqa";
readonly RALPLAN: "ralplan";
readonly DEEP_INTERVIEW: "deep-interview";
readonly SELF_IMPROVE: "self-improve";
};
/**
* Deprecated mode names removed in #1131 (pipeline unification).

2
dist/lib/mode-names.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"mode-names.d.ts","sourceRoot":"","sources":["../../src/lib/mode-names.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,gDAAgD;AAChD,eAAO,MAAM,UAAU;;;;;;;CAOb,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,qBAAqB;;;;CAIxB,CAAC;AAEX,gDAAgD;AAChD,MAAM,MAAM,QAAQ,GAAG,OAAO,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,QAAQ,EAOpC,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAOlE,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAQjF,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,0BAA0B,EAAE,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAK/E,CAAC"}
{"version":3,"file":"mode-names.d.ts","sourceRoot":"","sources":["../../src/lib/mode-names.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,gDAAgD;AAChD,eAAO,MAAM,UAAU;;;;;;;;;CASb,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,qBAAqB;;;;CAIxB,CAAC;AAEX,gDAAgD;AAChD,MAAM,MAAM,QAAQ,GAAG,OAAO,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,QAAQ,EASpC,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CASlE,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAUjF,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,0BAA0B,EAAE,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAO/E,CAAC"}

10
dist/lib/mode-names.js generated vendored
View File

@@ -13,6 +13,8 @@ export const MODE_NAMES = {
ULTRAWORK: 'ultrawork',
ULTRAQA: 'ultraqa',
RALPLAN: 'ralplan',
DEEP_INTERVIEW: 'deep-interview',
SELF_IMPROVE: 'self-improve',
};
/**
* Deprecated mode names removed in #1131 (pipeline unification).
@@ -34,6 +36,8 @@ export const ALL_MODE_NAMES = [
MODE_NAMES.ULTRAWORK,
MODE_NAMES.ULTRAQA,
MODE_NAMES.RALPLAN,
MODE_NAMES.DEEP_INTERVIEW,
MODE_NAMES.SELF_IMPROVE,
];
/**
* Mode state file mapping — the canonical filename for each mode's state file
@@ -46,6 +50,8 @@ export const MODE_STATE_FILE_MAP = {
[MODE_NAMES.ULTRAWORK]: 'ultrawork-state.json',
[MODE_NAMES.ULTRAQA]: 'ultraqa-state.json',
[MODE_NAMES.RALPLAN]: 'ralplan-state.json',
[MODE_NAMES.DEEP_INTERVIEW]: 'deep-interview-state.json',
[MODE_NAMES.SELF_IMPROVE]: 'self-improve-state.json',
};
/**
* Mode state files used by session-end cleanup.
@@ -58,6 +64,8 @@ export const SESSION_END_MODE_STATE_FILES = [
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAWORK], mode: MODE_NAMES.ULTRAWORK },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAQA], mode: MODE_NAMES.ULTRAQA },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPLAN], mode: MODE_NAMES.RALPLAN },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW], mode: MODE_NAMES.DEEP_INTERVIEW },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE], mode: MODE_NAMES.SELF_IMPROVE },
{ file: 'skill-active-state.json', mode: 'skill-active' },
];
/**
@@ -68,5 +76,7 @@ export const SESSION_METRICS_MODE_FILES = [
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPH], mode: MODE_NAMES.RALPH },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAWORK], mode: MODE_NAMES.ULTRAWORK },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPLAN], mode: MODE_NAMES.RALPLAN },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW], mode: MODE_NAMES.DEEP_INTERVIEW },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE], mode: MODE_NAMES.SELF_IMPROVE },
];
//# sourceMappingURL=mode-names.js.map

2
dist/lib/mode-names.js.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"mode-names.js","sourceRoot":"","sources":["../../src/lib/mode-names.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,gDAAgD;AAChD,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,SAAS,EAAE,WAAW;IACtB,IAAI,EAAE,MAAM;IACZ,KAAK,EAAE,OAAO;IACd,SAAS,EAAE,WAAW;IACtB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;CACV,CAAC;AAEX;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACnC,UAAU,EAAE,YAAY;IACxB,KAAK,EAAE,OAAO;IACd,QAAQ,EAAE,UAAU;CACZ,CAAC;AAKX;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwB;IACjD,UAAU,CAAC,SAAS;IACpB,UAAU,CAAC,IAAI;IACf,UAAU,CAAC,KAAK;IAChB,UAAU,CAAC,SAAS;IACpB,UAAU,CAAC,OAAO;IAClB,UAAU,CAAC,OAAO;CACV,CAAC;AAEX;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAuC;IACrE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,sBAAsB;IAC9C,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,iBAAiB;IACpC,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,kBAAkB;IACtC,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,sBAAsB;IAC9C,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,oBAAoB;IAC1C,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,oBAAoB;CAC3C,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAA8C;IACrF,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,SAAS,EAAE;IAC/E,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,IAAI,EAAE;IACrE,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,KAAK,EAAE;IACvE,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,SAAS,EAAE;IAC/E,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE;IAC3E,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE;IAC3E,EAAE,IAAI,EAAE,yBAAyB,EAAE,IAAI,EAAE,cAAc,EAAE;CAC1D,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAA8C;IACnF,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,SAAS,EAAE;IAC/E,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,KAAK,EAAE;IACvE,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,SAAS,EAAE;IAC/E,EAAE,IAAI,EAAE,mBAAmB,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE;CAC5E,CAAC"}
{"version":3,"file":"mode-names.js","sourceRoot":"","sources":["../../src/lib/mode-names.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=runtime-v2.gemini-preflight.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"runtime-v2.gemini-preflight.test.d.ts","sourceRoot":"","sources":["../../../src/team/__tests__/runtime-v2.gemini-preflight.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
const mocks = vi.hoisted(() => ({
createTeamSession: vi.fn(),
spawnWorkerInPane: vi.fn(),
sendToWorker: vi.fn(),
waitForPaneReady: vi.fn(),
applyMainVerticalLayout: vi.fn(),
tmuxExecAsync: vi.fn(),
queueInboxInstruction: vi.fn(),
}));
const modelContractMocks = vi.hoisted(() => ({
buildWorkerArgv: vi.fn((agentType, config) => [config?.resolvedBinaryPath ?? agentType ?? 'claude']),
resolveValidatedBinaryPath: vi.fn((agentType) => {
if (agentType === 'gemini')
throw new Error('Resolved CLI binary \'gemini\' to untrusted location: /tmp/gemini');
return `/usr/bin/${agentType ?? 'claude'}`;
}),
getContract: vi.fn((agentType) => ({ binary: agentType ?? 'claude' })),
getWorkerEnv: vi.fn(() => ({ OMC_TEAM_WORKER: 'issue2675-team/worker-1' })),
isPromptModeAgent: vi.fn(() => false),
getPromptModeArgs: vi.fn(() => []),
resolveClaudeWorkerModel: vi.fn(() => undefined),
}));
vi.mock('../../cli/tmux-utils.js', () => ({
tmuxExecAsync: mocks.tmuxExecAsync,
}));
vi.mock('../tmux-session.js', () => ({
createTeamSession: mocks.createTeamSession,
spawnWorkerInPane: mocks.spawnWorkerInPane,
sendToWorker: mocks.sendToWorker,
waitForPaneReady: mocks.waitForPaneReady,
paneHasActiveTask: vi.fn(() => false),
paneLooksReady: vi.fn(() => true),
applyMainVerticalLayout: mocks.applyMainVerticalLayout,
}));
vi.mock('../model-contract.js', () => ({
buildWorkerArgv: modelContractMocks.buildWorkerArgv,
resolveValidatedBinaryPath: modelContractMocks.resolveValidatedBinaryPath,
getContract: modelContractMocks.getContract,
getWorkerEnv: modelContractMocks.getWorkerEnv,
isPromptModeAgent: modelContractMocks.isPromptModeAgent,
getPromptModeArgs: modelContractMocks.getPromptModeArgs,
resolveClaudeWorkerModel: modelContractMocks.resolveClaudeWorkerModel,
}));
vi.mock('../mcp-comm.js', () => ({
queueInboxInstruction: mocks.queueInboxInstruction,
}));
describe('runtime-v2 Gemini preflight routing', () => {
let cwd = '';
beforeEach(() => {
vi.resetModules();
mocks.createTeamSession.mockResolvedValue({
sessionName: 'issue2675-session',
leaderPaneId: '%1',
workerPaneIds: [],
sessionMode: 'split-pane',
});
mocks.spawnWorkerInPane.mockResolvedValue(undefined);
mocks.waitForPaneReady.mockResolvedValue(true);
mocks.applyMainVerticalLayout.mockResolvedValue(undefined);
mocks.tmuxExecAsync.mockImplementation(async (args) => {
if (args[0] === 'split-window') {
return { stdout: '%2\n', stderr: '' };
}
return { stdout: '', stderr: '' };
});
mocks.queueInboxInstruction.mockResolvedValue({ ok: true, reason: 'transport_direct', transport: 'transport_direct' });
});
afterEach(async () => {
if (cwd)
await rm(cwd, { recursive: true, force: true });
});
it('keeps an explicitly routed gemini lane on gemini when strict preflight path probing false-negatives', async () => {
cwd = await mkdtemp(join(tmpdir(), 'issue2675-repro-'));
const { startTeamV2 } = await import('../runtime-v2.js');
const runtime = await startTeamV2({
teamName: 'issue2675-team',
workerCount: 1,
agentTypes: ['gemini'],
tasks: [{ subject: 'Review code', description: 'Review code', role: 'executor' }],
cwd,
pluginConfig: {
team: { roleRouting: { executor: { provider: 'gemini' } } },
},
});
expect(runtime.config.workers[0]?.worker_cli).toBe('gemini');
expect(modelContractMocks.buildWorkerArgv).toHaveBeenCalledWith('gemini', expect.objectContaining({
teamName: 'issue2675-team',
workerName: 'worker-1',
resolvedBinaryPath: 'gemini',
}));
});
});
//# sourceMappingURL=runtime-v2.gemini-preflight.test.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"runtime-v2.gemini-preflight.test.js","sourceRoot":"","sources":["../../../src/team/__tests__/runtime-v2.gemini-preflight.test.ts"],"names":[],"mappings":"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"}

2
dist/team/runtime-v2.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"runtime-v2.d.ts","sourceRoot":"","sources":["../../src/team/runtime-v2.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"runtime-v2.d.ts","sourceRoot":"","sources":["../../src/team/runtime-v2.ts"],"names":[],"mappings":"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"}

21
dist/team/runtime-v2.js generated vendored
View File

@@ -27,7 +27,7 @@ import { appendTeamEvent, emitMonitorDerivedEvents } from './events.js';
import { DEFAULT_TEAM_GOVERNANCE, DEFAULT_TEAM_TRANSPORT_POLICY, getConfigGovernance, } from './governance.js';
import { inferPhase } from './phase-controller.js';
import { validateTeamName } from './team-name.js';
import { buildWorkerArgv, resolveValidatedBinaryPath, getWorkerEnv as getModelWorkerEnv, isPromptModeAgent, getPromptModeArgs, resolveClaudeWorkerModel, } from './model-contract.js';
import { buildWorkerArgv, getContract, resolveValidatedBinaryPath, getWorkerEnv as getModelWorkerEnv, isPromptModeAgent, getPromptModeArgs, resolveClaudeWorkerModel, } from './model-contract.js';
import { createTeamSession, spawnWorkerInPane, sendToWorker, waitForPaneReady, paneHasActiveTask, paneLooksReady, applyMainVerticalLayout, } from './tmux-session.js';
import { composeInitialInbox, ensureWorkerStateDir, writeWorkerOverlay, generateTriggerMessage, generatePromptModeStartupPrompt, } from './worker-bootstrap.js';
import { queueInboxInstruction } from './mcp-comm.js';
@@ -106,6 +106,21 @@ function sanitizeTeamName(name) {
throw new Error(`Invalid team name: "${name}" produces empty slug after sanitization`);
return sanitized;
}
function shouldUseLaunchTimeCliResolution(reason) {
return /untrusted location|relative path/i.test(reason);
}
function resolvePreflightBinaryPath(agentType) {
try {
return { path: resolveValidatedBinaryPath(agentType), degraded: false };
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (shouldUseLaunchTimeCliResolution(reason)) {
return { path: getContract(agentType).binary, degraded: true, reason };
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Helper: check worker liveness via tmux pane
// ---------------------------------------------------------------------------
@@ -433,7 +448,7 @@ export async function startTeamV2(config) {
const missingBinaryReasons = [];
for (const agentType of [...new Set(agentTypes)]) {
try {
resolvedBinaryPaths[agentType] = resolveValidatedBinaryPath(agentType);
resolvedBinaryPaths[agentType] = resolvePreflightBinaryPath(agentType).path;
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
@@ -450,7 +465,7 @@ export async function startTeamV2(config) {
if (missingBinaryReasons.some((m) => m.agentType === provider))
continue;
try {
resolvedBinaryPaths[provider] = resolveValidatedBinaryPath(provider);
resolvedBinaryPaths[provider] = resolvePreflightBinaryPath(provider).path;
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);

2
dist/team/runtime-v2.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -312,6 +312,18 @@ describe('state-tools', () => {
});
expect(result.content[0].text).toContain('deep-interview');
});
it('should include self-improve mode when self-improve state is active', async () => {
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1 },
workingDirectory: TEST_DIR,
});
const result = await stateListActiveTool.handler({
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('self-improve');
});
it('should include team in status output when team state is active', async () => {
await stateWriteTool.handler({
mode: 'team',
@@ -326,6 +338,174 @@ describe('state-tools', () => {
expect(result.content[0].text).toContain('Status: team');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('deep-interview and self-improve appear in all-mode status listing', async () => {
const result = await stateGetStatusTool.handler({
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('deep-interview');
expect(result.content[0].text).toContain('self-improve');
});
});
// -----------------------------------------------------------------------
// Registry parity: deep-interview and self-improve as first-class modes
// -----------------------------------------------------------------------
describe('deep-interview and self-improve registry parity (T1)', () => {
it('writes deep-interview state to session-scoped path via MODE_CONFIGS routing', async () => {
const sessionId = 'di-registry-write';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning', round: 3 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'deep-interview-state.json');
expect(existsSync(statePath)).toBe(true);
});
it('writes self-improve state to session-scoped path via MODE_CONFIGS routing', async () => {
const sessionId = 'si-registry-write';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1, best_score: 0.85 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'self-improve-state.json');
expect(existsSync(statePath)).toBe(true);
});
it('reads deep-interview state back from session-scoped path', async () => {
const sessionId = 'di-registry-read';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning', ambiguity_score: 0.34 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const result = await stateReadTool.handler({
mode: 'deep-interview',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('current_phase');
expect(result.content[0].text).toContain('ambiguity_score');
});
it('reads self-improve state back from session-scoped path', async () => {
const sessionId = 'si-registry-read';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 2, generation: 5 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const result = await stateReadTool.handler({
mode: 'self-improve',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('tournament_round');
expect(result.content[0].text).toContain('generation');
});
it('clears deep-interview state file for given session', async () => {
const sessionId = 'di-registry-clear';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'analysis' },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const clearResult = await stateClearTool.handler({
mode: 'deep-interview',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(clearResult.content[0].text).toMatch(/cleared|Successfully/i);
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'deep-interview-state.json');
expect(existsSync(statePath)).toBe(false);
});
it('clears self-improve state file for given session', async () => {
const sessionId = 'si-registry-clear';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 3 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const clearResult = await stateClearTool.handler({
mode: 'self-improve',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(clearResult.content[0].text).toMatch(/cleared|Successfully/i);
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'self-improve-state.json');
expect(existsSync(statePath)).toBe(false);
});
it('state_get_status reports self-improve as active when state file is present', async () => {
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 2 },
workingDirectory: TEST_DIR,
});
const result = await stateGetStatusTool.handler({
mode: 'self-improve',
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('Status: self-improve');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('state_get_status reports deep-interview as active when state file is present', async () => {
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'contrarian' },
workingDirectory: TEST_DIR,
});
const result = await stateGetStatusTool.handler({
mode: 'deep-interview',
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('Status: deep-interview');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('deep-interview session isolation: write to session A does not appear under session B', async () => {
const sessionA = 'di-iso-a';
const sessionB = 'di-iso-b';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning' },
session_id: sessionA,
workingDirectory: TEST_DIR,
});
const resultB = await stateReadTool.handler({
mode: 'deep-interview',
session_id: sessionB,
workingDirectory: TEST_DIR,
});
expect(resultB.content[0].text).toContain('No state found');
});
it('self-improve session isolation: write to session A does not appear under session B', async () => {
const sessionA = 'si-iso-a';
const sessionB = 'si-iso-b';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1 },
session_id: sessionA,
workingDirectory: TEST_DIR,
});
const resultB = await stateReadTool.handler({
mode: 'self-improve',
session_id: sessionB,
workingDirectory: TEST_DIR,
});
expect(resultB.content[0].text).toContain('No state found');
});
});
describe('state_get_status', () => {
it('should return status for specific mode', async () => {

File diff suppressed because one or more lines are too long

2
dist/tools/state-tools.d.ts.map generated vendored
View File

@@ -1 +1 @@
{"version":3,"file":"state-tools.d.ts","sourceRoot":"","sources":["../../src/tools/state-tools.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"state-tools.d.ts","sourceRoot":"","sources":["../../src/tools/state-tools.ts"],"names":[],"mappings":"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"}

10
dist/tools/state-tools.js generated vendored
View File

@@ -12,20 +12,20 @@ import { atomicWriteJsonSync } from '../lib/atomic-write.js';
import { validatePayload } from '../lib/payload-limits.js';
import { canClearStateForSession, findSessionOwnedStateFiles } from '../lib/mode-state-io.js';
import { isModeActive, getActiveModes, getAllModeStatuses, clearModeState, getStateFilePath, MODE_CONFIGS, getActiveSessionsForMode } from '../hooks/mode-registry/index.js';
// ExecutionMode from mode-registry (5 modes)
// Canonical execution modes from mode-registry (deep-interview and self-improve
// are first-class modes with dedicated MODE_CONFIGS entries; ralplan remains an
// extra state-only mode handled via the registry-fallback path).
const EXECUTION_MODES = [
'autopilot', 'team', 'ralph', 'ultrawork', 'ultraqa'
'autopilot', 'team', 'ralph', 'ultrawork', 'ultraqa', 'deep-interview', 'self-improve'
];
// Extended type for state tools - includes state-bearing modes outside mode-registry
const STATE_TOOL_MODES = [
...EXECUTION_MODES,
'ralplan',
'omc-teams',
'deep-interview',
'self-improve',
'skill-active'
];
const EXTRA_STATE_ONLY_MODES = ['ralplan', 'omc-teams', 'deep-interview', 'self-improve', 'skill-active'];
const EXTRA_STATE_ONLY_MODES = ['ralplan', 'omc-teams', 'skill-active'];
const CANCEL_SIGNAL_TTL_MS = 30_000;
function readTeamNamesFromStateFile(statePath) {
if (!existsSync(statePath))

2
dist/tools/state-tools.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -687,4 +687,42 @@ describe('Skill active state cleanup on PostToolUse (issue #2103)', () => {
expect(typeof state.completed_at).toBe('string');
});
});
it('clears skill-active-state when deep-interview Skill completes', () => {
withTempDir((tempDir) => {
const sessionId = 'deep-interview-complete-01';
writeSkillStateFixtures(tempDir, sessionId, 'deep-interview');
const out = runPostToolVerifier({
tool_name: 'Skill',
tool_input: { skill: 'oh-my-claudecode:deep-interview' },
tool_response: { ok: true },
session_id: sessionId,
cwd: tempDir,
});
expect(out).toEqual({ continue: true, suppressOutput: true });
expect(existsSync(skillStatePath(tempDir, sessionId))).toBe(false);
expect(existsSync(legacySkillStatePath(tempDir))).toBe(false);
});
});
it('clears skill-active-state when self-improve Skill completes', () => {
withTempDir((tempDir) => {
const sessionId = 'self-improve-complete-01';
writeSkillStateFixtures(tempDir, sessionId, 'self-improve');
const out = runPostToolVerifier({
tool_name: 'Skill',
tool_input: { skill: 'oh-my-claudecode:self-improve' },
tool_response: { ok: true },
session_id: sessionId,
cwd: tempDir,
});
expect(out).toEqual({ continue: true, suppressOutput: true });
expect(existsSync(skillStatePath(tempDir, sessionId))).toBe(false);
expect(existsSync(legacySkillStatePath(tempDir))).toBe(false);
});
});
});

View File

@@ -918,6 +918,124 @@ $ ultrawork search the codebase`,
}
});
it('seeds workflow slot for explicit /deep-interview slash invocation in UserPromptSubmit', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-di-slash-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'di-slash-session';
const result = await processHook('keyword-detector', {
sessionId,
prompt: '/oh-my-claudecode:deep-interview explore auth flows',
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8')) as {
version?: number;
active_skills?: Record<string, { initialized_mode?: string; session_id?: string }>;
};
expect(slot.version).toBe(2);
expect(slot.active_skills?.['deep-interview']?.initialized_mode).toBe('deep-interview');
expect(slot.active_skills?.['deep-interview']?.session_id).toBe(sessionId);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot for explicit /self-improve slash invocation in UserPromptSubmit', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-si-slash-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'si-slash-session';
const result = await processHook('keyword-detector', {
sessionId,
prompt: '/self-improve refactor test coverage',
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8')) as {
version?: number;
active_skills?: Record<string, { initialized_mode?: string; session_id?: string }>;
};
expect(slot.version).toBe(2);
expect(slot.active_skills?.['self-improve']?.initialized_mode).toBe('self-improve');
expect(slot.active_skills?.['self-improve']?.session_id).toBe(sessionId);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot when Skill tool invokes oh-my-claudecode:deep-interview', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-di-skill-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'di-skill-session';
const result = await processHook('pre-tool-use', {
sessionId,
toolName: 'Skill',
toolInput: { skill: 'oh-my-claudecode:deep-interview' },
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8')) as {
version?: number;
active_skills?: Record<string, { initialized_mode?: string; session_id?: string }>;
};
expect(slot.version).toBe(2);
expect(slot.active_skills?.['deep-interview']?.initialized_mode).toBe('deep-interview');
expect(slot.active_skills?.['deep-interview']?.session_id).toBe(sessionId);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('seeds workflow slot when Skill tool invokes oh-my-claudecode:self-improve', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'bridge-routing-si-skill-'));
try {
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
const sessionId = 'si-skill-session';
const result = await processHook('pre-tool-use', {
sessionId,
toolName: 'Skill',
toolInput: { skill: 'oh-my-claudecode:self-improve' },
directory: tempDir,
});
expect(result.continue).toBe(true);
const slotPath = join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json');
expect(existsSync(slotPath)).toBe(true);
const slot = JSON.parse(readFileSync(slotPath, 'utf-8')) as {
version?: number;
active_skills?: Record<string, { initialized_mode?: string; session_id?: string }>;
};
expect(slot.version).toBe(2);
expect(slot.active_skills?.['self-improve']?.initialized_mode).toBe('self-improve');
expect(slot.active_skills?.['self-improve']?.session_id).toBe(sessionId);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('should handle session-start and return continue:true', async () => {
const input: HookInput = {
sessionId: 'test-session',

View File

@@ -69,7 +69,17 @@ import {
resolveOpenQuestionsPlanPath,
} from "../config/plan-output.js";
import { formatAutopilotRuntimeInsight } from "./autopilot/runtime-insight.js";
import { writeSkillActiveState } from "./skill-state/index.js";
import {
writeSkillActiveState,
isCanonicalWorkflowSkill,
upsertWorkflowSkillSlot,
markWorkflowSkillCompleted,
pruneExpiredWorkflowSkillTombstones,
readSkillActiveStateNormalized,
writeSkillActiveStateCopies,
type ActiveSkillSlot,
} from "./skill-state/index.js";
import { parseExplicitWorkflowSlashInvocation } from "./keyword-detector/index.js";
import {
ULTRAWORK_MESSAGE,
ULTRATHINK_MESSAGE,
@@ -860,10 +870,6 @@ function getPromptText(input: HookInput): string {
return "";
}
function isExplicitRalplanSlashInvocation(promptText: string): boolean {
return /^\s*\/(?:oh-my-claudecode:)?ralplan(?:\s|$)/i.test(promptText);
}
function isExplicitAskSlashInvocation(promptText: string): boolean {
return /^\s*\/(?:oh-my-claudecode:)?ask\s+(?:claude|codex|gemini)\b/i.test(promptText);
}
@@ -886,6 +892,175 @@ function activateRalplanStartupState(directory: string, sessionId?: string): voi
);
}
/**
* Resolve the on-disk path of the mode-specific state file for a workflow
* skill. Returns the session-scoped path when a session id is available, else
* the root path. Used to persist `mode_state_path` on the workflow slot so
* downstream consumers can locate the mode payload.
*/
function resolveWorkflowSlotModeStatePath(
directory: string,
skillName: string,
sessionId?: string,
): string {
const paths = getModeStatePaths(directory, skillName, sessionId);
return paths[0] ?? "";
}
/**
* Seed (or refresh) a canonical workflow-slot entry in the dual-copy ledger
* via the only sanctioned helper, `writeSkillActiveStateCopies()`. Returns
* `true` when at least one copy was written, `false` on best-effort failure.
*/
function seedWorkflowSlotForSkill(
directory: string,
skillName: string,
sessionId: string | undefined,
source: string,
parentSkill?: string | null,
): boolean {
if (!isCanonicalWorkflowSkill(skillName)) return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
const pruned = pruneExpiredWorkflowSkillTombstones(current);
// Resolve mode-state file pointers eagerly so downstream readers can
// locate the mode payload without re-deriving the path.
const rootStatePath = resolveStatePathSafe("skill-active", directory);
const sessionStatePath = sessionId
? resolveSessionStatePathSafe("skill-active", sessionId, directory)
: "";
const modeStatePath = resolveWorkflowSlotModeStatePath(
directory,
normalized,
sessionId,
);
const slotData: Partial<ActiveSkillSlot> = {
session_id: sessionId ?? "",
mode_state_path: modeStatePath,
initialized_mode: normalized,
initialized_state_path: rootStatePath,
initialized_session_state_path: sessionStatePath,
source,
};
if (parentSkill !== undefined) {
slotData.parent_skill = parentSkill;
}
const next = upsertWorkflowSkillSlot(pruned, normalized, slotData);
return writeSkillActiveStateCopies(directory, next, sessionId);
} catch {
return false;
}
}
/**
* Idempotently confirm a workflow slot — refreshes `last_confirmed_at` when
* the slot is live. No-op when the slot is missing or already tombstoned.
*/
function confirmWorkflowSlot(
directory: string,
skillName: string,
sessionId?: string,
): boolean {
if (!isCanonicalWorkflowSkill(skillName)) return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
const slot = current.active_skills[normalized];
if (!slot || slot.completed_at) return false;
const next = upsertWorkflowSkillSlot(current, normalized, {
last_confirmed_at: new Date().toISOString(),
});
return writeSkillActiveStateCopies(directory, next, sessionId);
} catch {
return false;
}
}
/**
* Soft-tombstone a workflow slot on completion. The slot is retained until
* the TTL pruner removes it, so late-arriving stop hooks see consistent
* state.
*/
function tombstoneWorkflowSlot(
directory: string,
skillName: string,
sessionId?: string,
): boolean {
if (!isCanonicalWorkflowSkill(skillName)) return false;
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, "");
try {
const current = readSkillActiveStateNormalized(directory, sessionId);
if (!current.active_skills[normalized]) return false;
const next = markWorkflowSkillCompleted(current, normalized);
return writeSkillActiveStateCopies(directory, next, sessionId);
} catch {
return false;
}
}
function resolveStatePathSafe(stateName: string, directory: string): string {
try {
// Lazy resolve to avoid a circular import; same module is imported in
// skill-state via the mode-paths registry.
return join(getOmcRoot(directory), "state", `${stateName}-state.json`);
} catch {
return "";
}
}
function resolveSessionStatePathSafe(
stateName: string,
sessionId: string,
directory: string,
): string {
try {
return join(
getOmcRoot(directory),
"state",
"sessions",
sessionId,
`${stateName}-state.json`,
);
} catch {
return "";
}
}
/**
* Mode-specific seeding entrypoints invoked alongside the workflow slot when
* the user issues an explicit slash command. Each branch is a no-op when the
* mode does not require pre-skill state (e.g. `team`, where the team skill
* itself owns initial state via worker spawning).
*/
async function seedModeStateForExplicitWorkflowSlash(
skill: string,
directory: string,
promptText: string,
sessionId?: string,
): Promise<void> {
switch (skill) {
case "ralplan":
activateRalplanStartupState(directory, sessionId);
return;
case "autopilot":
await seedAutopilotStartupState(directory, promptText, sessionId);
return;
default:
// ralph / ultrawork / team / ultraqa / deep-interview / self-improve
// own their state activation inside their own Skill PostToolUse handlers.
// Pre-Skill seeding for these would clobber existing in-flight state
// (e.g. nested `autopilot → ralph`); the workflow slot alone is enough
// to keep stop-hook enforcement from premature termination.
return;
}
}
/**
* Process keyword detection hook
* Detects magic keywords and returns injection message
@@ -916,21 +1091,45 @@ async function processKeywordDetector(input: HookInput): Promise<HookOutput> {
const sessionId = input.sessionId;
const directory = resolveToWorktreeRoot(input.directory);
const messages: string[] = [];
const explicitRalplanSlashInvocation =
isExplicitRalplanSlashInvocation(promptText);
if (explicitRalplanSlashInvocation) {
activateRalplanStartupState(directory, sessionId);
return {
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext:
`[RALPLAN INIT] Explicit /ralplan invoke detected during UserPromptSubmit.\n` +
`ralplan state is armed for startup and marked awaiting confirmation, so the stop hook will not block this initialization path.\n` +
`Proceed immediately with the consensus planning workflow for:\n${promptText}`,
},
} as HookOutput & { hookSpecificOutput: Record<string, unknown> };
// Unified explicit slash invocation handler — covers all 8 canonical
// workflow skills (autopilot, ralph, team, ultrawork, ultraqa,
// deep-interview, ralplan, self-improve). Seeds the workflow slot via the
// sanctioned dual-copy helper BEFORE the Skill tool fires, and seeds the
// mode-specific state file when the mode requires pre-Skill state. The
// ralplan path additionally returns the legacy [RALPLAN INIT] context
// injection so existing routing tests remain green.
const explicitSlash = parseExplicitWorkflowSlashInvocation(promptText);
if (explicitSlash) {
seedWorkflowSlotForSkill(
directory,
explicitSlash.skill,
sessionId,
"prompt-submit:explicit-slash",
);
await seedModeStateForExplicitWorkflowSlash(
explicitSlash.skill,
directory,
promptText,
sessionId,
);
if (explicitSlash.skill === "ralplan") {
return {
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext:
`[RALPLAN INIT] Explicit /ralplan invoke detected during UserPromptSubmit.\n` +
`ralplan state is armed for startup and marked awaiting confirmation, so the stop hook will not block this initialization path.\n` +
`Proceed immediately with the consensus planning workflow for:\n${promptText}`,
},
} as HookOutput & { hookSpecificOutput: Record<string, unknown> };
}
// For non-ralplan workflow slash invocations, fall through so the regular
// keyword pipeline still emits the mode message constants and routes
// through the normal activation path. The workflow slot is already armed
// so the stop-hook will treat the upcoming Skill invocation as authorized.
}
// Record prompt submission time in HUD state
@@ -1891,6 +2090,21 @@ function processPreToolUse(input: HookInput): HookOutput {
if (isConsensusPlanningSkillInvocation(skillName, input.toolInput)) {
activateRalplanState(directory, input.sessionId);
}
// Workflow-slot ledger: when the Skill tool is invoked for one of the
// 8 canonical workflow skills, ensure the slot is present and freshly
// confirmed. Seed first (idempotent — preserves existing fields when
// the slot was already armed during UserPromptSubmit), then refresh
// `last_confirmed_at` so stop-hook reconciliation can distinguish a
// truly idle workflow from an in-flight one.
if (isCanonicalWorkflowSkill(skillName)) {
seedWorkflowSlotForSkill(
directory,
skillName,
input.sessionId,
"pre-tool:skill",
);
confirmWorkflowSlot(directory, skillName, input.sessionId);
}
} catch {
// Skill-state/state-sync writes are best-effort; don't fail the hook on error.
}
@@ -2136,6 +2350,13 @@ async function processPostToolUse(input: HookInput): Promise<HookOutput> {
if (!currentState || !currentState.active || currentState.skill_name === completingSkill) {
clearSkillActiveState(directory, input.sessionId);
}
// Workflow-slot ledger: tombstone the canonical workflow slot when its
// Skill invocation completes. Soft-tombstoning (rather than hard delete)
// preserves the slot until the TTL pruner removes it — late-arriving
// stop hooks see consistent state instead of a missing slot.
if (skillName && isCanonicalWorkflowSkill(skillName)) {
tombstoneWorkflowSlot(directory, skillName, input.sessionId);
}
if (isConsensusPlanningSkillInvocation(skillName, input.toolInput)) {
deactivateRalplanState(directory, input.sessionId);
}

View File

@@ -11,6 +11,7 @@ import {
isUnderspecifiedForExecution,
applyRalplanGate,
NON_LATIN_SCRIPT_PATTERN,
parseExplicitWorkflowSlashInvocation,
} from '../index.js';
// Mock isTeamEnabled
@@ -2053,4 +2054,190 @@ This article argues that fake popularity signals damage trust in open source.`;
});
});
});
// -------------------------------------------------------------------------
// Intent-pattern guards (spec h) — file paths, code fences, and backticks
// must NOT trigger keyword detection
// -------------------------------------------------------------------------
describe('intent-pattern guards: file paths and code blocks (spec h)', () => {
it('file path /ralph-logs/foo.txt does NOT detect ralph', () => {
const result = detectKeywordsWithType('/ralph-logs/foo.txt');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('path segment /path/to/ralph-config.json does NOT detect ralph', () => {
const result = detectKeywordsWithType('check /path/to/ralph-config.json for settings');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('fenced code block containing /ralph does NOT detect ralph', () => {
const result = detectKeywordsWithType('```\n/ralph fix the bug\n```');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('inline backtick `/ralph` does NOT detect ralph', () => {
const result = detectKeywordsWithType('use `/ralph` to start the loop');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('inline backtick `/oh-my-claudecode:ralph` does NOT detect ralph', () => {
const result = detectKeywordsWithType('run `/oh-my-claudecode:ralph` if needed');
expect(result.find((r) => r.type === 'ralph')).toBeUndefined();
});
it('file path /autopilot-runs/log.txt does NOT detect autopilot', () => {
const result = detectKeywordsWithType('/autopilot-runs/log.txt');
expect(result.find((r) => r.type === 'autopilot')).toBeUndefined();
});
it('fenced code block containing /ultrawork does NOT detect ultrawork', () => {
const result = detectKeywordsWithType('```bash\n/ultrawork search codebase\n```');
expect(result.find((r) => r.type === 'ultrawork')).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Unified prefix detector (spec g) — /skill, /omc:skill, /oh-my-claudecode:skill
// all seed the same canonical state (T3 implementation required)
// -------------------------------------------------------------------------
describe('unified prefix detector: /omc: and /oh-my-claudecode: forms (spec g)', () => {
it('/omc:ralph fix auth detects ralph', () => {
const result = detectKeywordsWithType('/omc:ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/oh-my-claudecode:ralph fix auth detects ralph', () => {
const result = detectKeywordsWithType('/oh-my-claudecode:ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/omc:autopilot implement feature detects autopilot', () => {
const result = detectKeywordsWithType('/omc:autopilot implement feature');
expect(result.find((r) => r.type === 'autopilot')).toBeDefined();
});
it('/omc:ultrawork search codebase detects ultrawork', () => {
const result = detectKeywordsWithType('/omc:ultrawork search codebase');
expect(result.find((r) => r.type === 'ultrawork')).toBeDefined();
});
it('/ralph fix auth at message start detects ralph (explicit slash command)', () => {
const result = detectKeywordsWithType('/ralph fix auth');
expect(result.find((r) => r.type === 'ralph')).toBeDefined();
});
it('/autopilot at message start detects autopilot', () => {
const result = detectKeywordsWithType('/autopilot ship the new feature end to end');
expect(result.find((r) => r.type === 'autopilot')).toBeDefined();
});
it('/ultrawork at message start detects ultrawork', () => {
const result = detectKeywordsWithType('/ultrawork investigate this report');
expect(result.find((r) => r.type === 'ultrawork')).toBeDefined();
});
it('/deep-interview at message start detects deep-interview', () => {
const result = detectKeywordsWithType('/deep-interview about the architecture');
expect(result.find((r) => r.type === 'deep-interview')).toBeDefined();
});
it('/ralplan at message start detects ralplan', () => {
const result = detectKeywordsWithType('/ralplan issue #2622');
expect(result.find((r) => r.type === 'ralplan')).toBeDefined();
});
it('explicit slash detection does not duplicate the same keyword type', () => {
const result = detectKeywordsWithType('/ralph fix auth');
const ralphMatches = result.filter((r) => r.type === 'ralph');
expect(ralphMatches.length).toBe(1);
});
});
// -------------------------------------------------------------------------
// parseExplicitWorkflowSlashInvocation — unit tests (spec g)
// -------------------------------------------------------------------------
describe('parseExplicitWorkflowSlashInvocation — parser unit tests (spec g)', () => {
it('returns null for empty string', () => {
expect(parseExplicitWorkflowSlashInvocation('')).toBeNull();
});
it('returns null for non-slash prompt', () => {
expect(parseExplicitWorkflowSlashInvocation('ralph fix auth')).toBeNull();
});
it('parses bare /ralph with args', () => {
const result = parseExplicitWorkflowSlashInvocation('/ralph fix the auth flow');
expect(result).not.toBeNull();
expect(result!.skill).toBe('ralph');
expect(result!.args).toBe('fix the auth flow');
});
it('parses /omc:ralph and normalizes skill name', () => {
const result = parseExplicitWorkflowSlashInvocation('/omc:ralph debug this');
expect(result).not.toBeNull();
expect(result!.skill).toBe('ralph');
});
it('parses /oh-my-claudecode:ralph and normalizes skill name', () => {
const result = parseExplicitWorkflowSlashInvocation('/oh-my-claudecode:ralph debug this');
expect(result).not.toBeNull();
expect(result!.skill).toBe('ralph');
});
it('parses /autopilot with args', () => {
const result = parseExplicitWorkflowSlashInvocation('/autopilot ship the feature');
expect(result!.skill).toBe('autopilot');
expect(result!.args).toBe('ship the feature');
});
it('parses /deep-interview at message start', () => {
const result = parseExplicitWorkflowSlashInvocation('/deep-interview about system design');
expect(result!.skill).toBe('deep-interview');
});
it('parses /self-improve at message start', () => {
const result = parseExplicitWorkflowSlashInvocation('/self-improve');
expect(result!.skill).toBe('self-improve');
expect(result!.args).toBe('');
});
it('returns null for /ralph-logs/foo.txt (path lookahead prevents match)', () => {
expect(parseExplicitWorkflowSlashInvocation('/ralph-logs/foo.txt')).toBeNull();
});
it('returns null for /ralph inside fenced code block', () => {
expect(parseExplicitWorkflowSlashInvocation('```\n/ralph fix this\n```')).toBeNull();
});
it('returns null for /ralph inside inline backtick', () => {
expect(parseExplicitWorkflowSlashInvocation('use `/ralph` to start')).toBeNull();
});
it('is case-insensitive: /RALPH is detected', () => {
const result = parseExplicitWorkflowSlashInvocation('/RALPH fix auth');
expect(result!.skill).toBe('ralph');
});
it('leading whitespace before / is allowed', () => {
const result = parseExplicitWorkflowSlashInvocation(' /ralph fix auth');
expect(result!.skill).toBe('ralph');
});
it('/ralph with no args returns empty args string', () => {
const result = parseExplicitWorkflowSlashInvocation('/ralph');
expect(result!.skill).toBe('ralph');
expect(result!.args).toBe('');
});
it('all three prefix forms produce the same skill name for autopilot', () => {
const bare = parseExplicitWorkflowSlashInvocation('/autopilot go');
const omc = parseExplicitWorkflowSlashInvocation('/omc:autopilot go');
const full = parseExplicitWorkflowSlashInvocation('/oh-my-claudecode:autopilot go');
expect(bare!.skill).toBe('autopilot');
expect(omc!.skill).toBe('autopilot');
expect(full!.skill).toBe('autopilot');
});
});
});

View File

@@ -71,6 +71,85 @@ const KEYWORD_PRIORITY: KeywordType[] = [
'ultrathink', 'deepsearch', 'analyze', 'deep-interview', 'codex', 'gemini'
];
/**
* Canonical workflow skills detected via explicit slash invocation.
* Mirrors `CANONICAL_WORKFLOW_SKILLS` in `skill-state/index.ts`. Listed here
* (rather than imported) to keep the keyword-detector free of cross-module
* dependencies on skill-state.
*/
const CANONICAL_WORKFLOW_SLASH_SKILLS = [
'autopilot',
'ralph',
'team',
'ultrawork',
'ultraqa',
'deep-interview',
'ralplan',
'self-improve',
] as const;
export type CanonicalWorkflowSlashSkill =
(typeof CANONICAL_WORKFLOW_SLASH_SKILLS)[number];
/**
* Map workflow slash skills to keyword types so explicit slash invocations
* surface alongside ordinary keyword detection. Skills with no dedicated
* KeywordType (`ultraqa`, `self-improve`) are intentionally absent — the
* bridge handles their seeding via the parser result instead of through the
* keyword-priority loop.
*/
const SLASH_SKILL_TO_KEYWORD_TYPE: Partial<
Record<CanonicalWorkflowSlashSkill, KeywordType>
> = {
autopilot: 'autopilot',
ralph: 'ralph',
team: 'team',
ultrawork: 'ultrawork',
'deep-interview': 'deep-interview',
ralplan: 'ralplan',
};
const WORKFLOW_SLASH_PATTERN = new RegExp(
'^\\s*/(?:oh-my-claudecode:|omc:)?(' +
CANONICAL_WORKFLOW_SLASH_SKILLS
.map((skill) => skill.replace(/-/g, '\\-'))
.join('|') +
')(?=\\s|$|[?!.,;:])',
'i',
);
export interface ExplicitWorkflowSlashInvocation {
/** Canonical workflow skill name (lowercase, no `oh-my-claudecode:` prefix). */
skill: CanonicalWorkflowSlashSkill;
/** Trailing arguments after the slash command. */
args: string;
/** Raw matched prefix (including any namespace prefix and the skill name). */
raw: string;
}
/**
* Parse an explicit workflow slash invocation at the start of a prompt.
*
* Recognizes `/<skill>`, `/omc:<skill>`, and `/oh-my-claudecode:<skill>` for
* the canonical workflow skill list. Code fences and inline backticks are
* stripped first so quoted commands do not match. The trailing lookahead
* (whitespace, end-of-text, or punctuation) prevents file paths like
* `/ralph-logs/foo.txt` from matching `/ralph`.
*
* Returns `null` when no explicit invocation is present.
*/
export function parseExplicitWorkflowSlashInvocation(
promptText: string,
): ExplicitWorkflowSlashInvocation | null {
if (typeof promptText !== 'string' || promptText.length === 0) return null;
const stripped = removeCodeBlocks(promptText);
const match = WORKFLOW_SLASH_PATTERN.exec(stripped);
if (!match) return null;
const skill = match[1].toLowerCase() as CanonicalWorkflowSlashSkill;
const args = stripped.slice(match[0].length).trim();
return { skill, args, raw: match[0] };
}
/**
* Remove code blocks from text to prevent false positives
* Handles both fenced code blocks and inline code
@@ -524,6 +603,26 @@ export function detectKeywordsWithType(
_agentName?: string
): DetectedKeyword[] {
const detected: DetectedKeyword[] = [];
// Check for an explicit canonical workflow slash invocation BEFORE sanitization.
// The general sanitizer strips bare `/word` tokens as file paths, so bare
// commands like `/ralph fix auth` would otherwise never match. This must be
// robust to surrounding whitespace, namespace prefixes (`/omc:`,
// `/oh-my-claudecode:`), and code-fence/backtick wrapping (handled inside
// the parser via removeCodeBlocks).
const explicitSlash = parseExplicitWorkflowSlashInvocation(text);
const explicitSlashType = explicitSlash
? SLASH_SKILL_TO_KEYWORD_TYPE[explicitSlash.skill]
: undefined;
if (explicitSlash && explicitSlashType) {
const position = Math.max(0, text.indexOf(explicitSlash.raw.trim()));
detected.push({
type: explicitSlashType,
keyword: explicitSlash.raw.trim(),
position,
});
}
const cleanedText = sanitizeForKeywordDetection(text);
// Check each keyword type
@@ -533,6 +632,12 @@ export function detectKeywordsWithType(
continue;
}
// Skip the type that the explicit-slash detector already surfaced so we
// do not emit duplicate entries for the same intent.
if (explicitSlashType && type === explicitSlashType) {
continue;
}
const pattern = KEYWORD_PATTERNS[type];
const match =
type === 'ralplan'

View File

@@ -78,6 +78,16 @@ const MODE_CONFIGS: Record<ExecutionMode, ModeConfig> = {
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAQA],
activeProperty: "active",
},
[MODE_NAMES.DEEP_INTERVIEW]: {
name: "Deep Interview",
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW],
activeProperty: "active",
},
[MODE_NAMES.SELF_IMPROVE]: {
name: "Self Improve",
stateFile: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE],
activeProperty: "active",
},
};
// Export for use in other modules
@@ -141,13 +151,75 @@ export function getGlobalStateFilePath(_mode: ExecutionMode): string | null {
}
/**
* Check if a JSON-based mode is active by reading its state file
* Workflow-slot tombstone TTL. Matches `WORKFLOW_TOMBSTONE_TTL_MS` in
* `src/hooks/skill-state/index.ts` — kept local here to preserve the
* "mode-registry uses ONLY file-based detection" invariant (no imports from
* hook modules that themselves depend on the registry).
*/
const WORKFLOW_SLOT_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Consult the session-local workflow ledger for a tombstoned slot.
*
* Returns `true` when the workflow ledger records the mode as tombstoned
* (soft-completed) AND the tombstone has not yet TTL-expired. Used to veto
* stale mode files from crashed sessions that never tore their own state down.
*
* Returns `false` for any shape we can't parse, any missing file, any live
* slot, and any slot whose tombstone already expired — so the legacy
* mode-file fallback remains authoritative whenever the ledger is silent.
*/
function isWorkflowSlotTombstonedForMode(
cwd: string,
mode: ExecutionMode,
sessionId?: string,
now: number = Date.now(),
): boolean {
try {
const ledgerPath = sessionId
? resolveSessionStatePath("skill-active", sessionId, cwd)
: join(getStateDir(cwd), "skill-active-state.json");
if (!existsSync(ledgerPath)) return false;
const raw = JSON.parse(readFileSync(ledgerPath, "utf-8")) as Record<
string,
unknown
>;
const slots = raw.active_skills;
if (!slots || typeof slots !== "object") return false;
const slot = (slots as Record<string, unknown>)[mode];
if (!slot || typeof slot !== "object") return false;
const completedAt = (slot as Record<string, unknown>).completed_at;
if (typeof completedAt !== "string" || completedAt.length === 0)
return false;
const tombstonedAt = new Date(completedAt).getTime();
if (!Number.isFinite(tombstonedAt)) return false;
return now - tombstonedAt < WORKFLOW_SLOT_TOMBSTONE_TTL_MS;
} catch {
return false;
}
}
/**
* Check if a JSON-based mode is active by reading its state file.
*
* Workflow-slot override: when the session workflow ledger records this mode
* as tombstoned (soft-completed), the stale per-mode state file is ignored so
* a fresh invocation can proceed without clearing artifacts manually. Live
* slots and absent slots both defer to the per-mode state file (legacy
* fallback preserved during the transition window).
*/
function isJsonModeActive(
cwd: string,
mode: ExecutionMode,
sessionId?: string,
): boolean {
if (isWorkflowSlotTombstonedForMode(cwd, mode, sessionId)) {
return false;
}
const config = MODE_CONFIGS[mode];
// When sessionId is provided, ONLY check session-scoped path — no legacy fallback.

View File

@@ -9,7 +9,9 @@ export type ExecutionMode =
| 'team'
| 'ralph'
| 'ultrawork'
| 'ultraqa';
| 'ultraqa'
| 'deep-interview'
| 'self-improve';
export interface ModeConfig {
/** Display name for the mode */

View File

@@ -272,4 +272,92 @@ describe('persistent-mode skill-state stop integration (issue #1033)', () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
// -----------------------------------------------------------------------
// Registry parity: deep-interview and self-improve canonical stop behavior
// -----------------------------------------------------------------------
it('blocks stop when deep-interview skill is actively executing', async () => {
const sessionId = 'session-di-stop-block';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, sessionId, 'deep-interview');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(true);
expect(result.message).toContain('deep-interview');
expect(result.message).toContain('SKILL ACTIVE');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('blocks stop when self-improve skill is actively executing', async () => {
const sessionId = 'session-si-stop-block';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, sessionId, 'self-improve');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(true);
expect(result.message).toContain('self-improve');
expect(result.message).toContain('SKILL ACTIVE');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('allows stop when deep-interview skill state is stale', async () => {
const sessionId = 'session-di-stop-stale';
const tempDir = makeTempProject();
try {
const past = new Date(Date.now() - 35 * 60 * 1000).toISOString(); // 35 min ago
writeSkillState(tempDir, sessionId, 'deep-interview', {
started_at: past,
last_checked_at: past,
stale_ttl_ms: 30 * 60 * 1000, // 30 min TTL (heavy protection)
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('allows stop when self-improve skill state is stale', async () => {
const sessionId = 'session-si-stop-stale';
const tempDir = makeTempProject();
try {
const past = new Date(Date.now() - 35 * 60 * 1000).toISOString();
writeSkillState(tempDir, sessionId, 'self-improve', {
started_at: past,
last_checked_at: past,
stale_ttl_ms: 30 * 60 * 1000,
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('respects session isolation for deep-interview skill state', async () => {
const sessionId = 'session-di-iso-a';
const tempDir = makeTempProject();
try {
writeSkillState(tempDir, 'session-di-iso-b', 'deep-interview');
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,289 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { checkPersistentModes } from '../index.js';
function makeTempProject(): string {
const tempDir = mkdtempSync(join(tmpdir(), 'wf-gate-'));
execFileSync('git', ['init'], { cwd: tempDir, stdio: 'pipe' });
return tempDir;
}
function writeWorkflowLedger(
tempDir: string,
sessionId: string,
slots: Record<string, { completedAt?: string }>,
): void {
const active_skills: Record<string, unknown> = {};
for (const [skill, opts] of Object.entries(slots)) {
active_skills[skill] = {
skill_name: skill,
started_at: new Date(Date.now() - 60_000).toISOString(),
completed_at: opts.completedAt ?? null,
session_id: sessionId,
mode_state_path: `${skill}-state.json`,
initialized_mode: skill,
initialized_state_path: join(tempDir, '.omc', 'state', 'skill-active-state.json'),
initialized_session_state_path: join(tempDir, '.omc', 'state', 'sessions', sessionId, 'skill-active-state.json'),
};
}
const payload = JSON.stringify({ version: 2, active_skills }, null, 2);
const rootDir = join(tempDir, '.omc', 'state');
mkdirSync(rootDir, { recursive: true });
writeFileSync(join(rootDir, 'skill-active-state.json'), payload);
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, 'skill-active-state.json'), payload);
}
function writeRalphState(tempDir: string, sessionId: string): void {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'ralph-state.json'),
JSON.stringify({
active: true,
iteration: 1,
max_iterations: 10,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
prompt: 'Test task',
session_id: sessionId,
project_path: tempDir,
linked_ultrawork: false,
}, null, 2),
);
}
describe('workflow-gating: kill switches (spec i)', () => {
let savedDisableOmc: string | undefined;
let savedSkipHooks: string | undefined;
beforeEach(() => {
savedDisableOmc = process.env.DISABLE_OMC;
savedSkipHooks = process.env.OMC_SKIP_HOOKS;
});
afterEach(() => {
if (savedDisableOmc === undefined) {
delete process.env.DISABLE_OMC;
} else {
process.env.DISABLE_OMC = savedDisableOmc;
}
if (savedSkipHooks === undefined) {
delete process.env.OMC_SKIP_HOOKS;
} else {
process.env.OMC_SKIP_HOOKS = savedSkipHooks;
}
});
it('DISABLE_OMC=1 bypasses all stop gating', async () => {
process.env.DISABLE_OMC = '1';
const result = await checkPersistentModes('kill-sw-1', undefined);
expect(result.shouldBlock).toBe(false);
expect(result.mode).toBe('none');
});
it('DISABLE_OMC=true bypasses all stop gating', async () => {
process.env.DISABLE_OMC = 'true';
const result = await checkPersistentModes('kill-sw-2', undefined);
expect(result.shouldBlock).toBe(false);
});
it('OMC_SKIP_HOOKS=persistent-mode bypasses stop gating', async () => {
process.env.OMC_SKIP_HOOKS = 'persistent-mode';
const result = await checkPersistentModes('kill-sw-3', undefined);
expect(result.shouldBlock).toBe(false);
expect(result.mode).toBe('none');
});
it('OMC_SKIP_HOOKS=stop-continuation bypasses stop gating', async () => {
process.env.OMC_SKIP_HOOKS = 'stop-continuation';
const result = await checkPersistentModes('kill-sw-4', undefined);
expect(result.shouldBlock).toBe(false);
});
it('OMC_SKIP_HOOKS with comma-separated list bypasses when persistent-mode is included', async () => {
process.env.OMC_SKIP_HOOKS = 'some-hook,persistent-mode,other-hook';
const result = await checkPersistentModes('kill-sw-5', undefined);
expect(result.shouldBlock).toBe(false);
});
});
describe('workflow-gating: tombstoned slot suppresses stale mode files (spec j)', () => {
it('tombstoned ralph slot suppresses ralph-state.json check', async () => {
const sessionId = 'tomb-ralph-01';
const tempDir = makeTempProject();
try {
// Write a ralph-state.json that would block if the workflow slot were live
writeRalphState(tempDir, sessionId);
// Write workflow ledger with ralph slot tombstoned
writeWorkflowLedger(tempDir, sessionId, {
'ralph': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
// Tombstoned ralph slot → runRalphPriority() returns null → no block
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned autopilot slot suppresses autopilot mode check', async () => {
const sessionId = 'tomb-auto-01';
const tempDir = makeTempProject();
try {
// Write autopilot-state.json in session state dir
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'autopilot-state.json'),
JSON.stringify({
active: true,
iteration: 1,
max_iterations: 5,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
session_id: sessionId,
project_path: tempDir,
phase: 'plan',
prd: { stories: [] },
}, null, 2),
);
// Tombstone the autopilot slot
writeWorkflowLedger(tempDir, sessionId, {
'autopilot': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned ralplan slot suppresses ralplan mode check', async () => {
const sessionId = 'tomb-ralplan-01';
const tempDir = makeTempProject();
try {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'ralplan-state.json'),
JSON.stringify({
active: true,
phase: 'planner',
session_id: sessionId,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
awaiting_confirmation: false,
}, null, 2),
);
writeWorkflowLedger(tempDir, sessionId, {
'ralplan': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('tombstoned ultrawork slot suppresses ultrawork mode check', async () => {
const sessionId = 'tomb-ulw-01';
const tempDir = makeTempProject();
try {
const stateDir = join(tempDir, '.omc', 'state', 'sessions', sessionId);
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'ultrawork-state.json'),
JSON.stringify({
active: true,
session_id: sessionId,
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
awaiting_confirmation: false,
tasks: [],
current_task_index: 0,
}, null, 2),
);
writeWorkflowLedger(tempDir, sessionId, {
'ultrawork': { completedAt: new Date(Date.now() - 60_000).toISOString() },
});
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('live ralph slot without tombstone blocks (control: tombstone guard is doing the work)', async () => {
const sessionId = 'tomb-ctrl-01';
const tempDir = makeTempProject();
try {
writeRalphState(tempDir, sessionId);
// Write workflow ledger with ralph slot LIVE (no completed_at)
writeWorkflowLedger(tempDir, sessionId, {
'ralph': {},
});
const result = await checkPersistentModes(sessionId, tempDir);
// Ralph-state.json is active + slot is live → should block
expect(result.shouldBlock).toBe(true);
expect(result.mode).toBe('ralph');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('workflow-gating: authority-first ordering for nested skills (spec f)', () => {
it('returns shouldBlock=false when no active mode state files exist regardless of empty ledger', async () => {
const sessionId = 'auth-empty-01';
const tempDir = makeTempProject();
try {
const result = await checkPersistentModes(sessionId, tempDir);
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('autopilot workflow authority resolved from ledger root slot (spec f invariant)', async () => {
const sessionId = 'auth-ap-01';
const tempDir = makeTempProject();
try {
// Write autopilot as root with ralph as tombstoned child — ledger authority = autopilot
writeWorkflowLedger(tempDir, sessionId, {
'autopilot': {},
'ralph': { completedAt: new Date(Date.now() - 30_000).toISOString() },
});
// No mode state files → no actual blocking (tests the routing path, not blocking)
const result = await checkPersistentModes(sessionId, tempDir);
// Without autopilot-state.json, autopilot check returns null → result is shouldBlock=false
expect(result.shouldBlock).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -1563,6 +1563,41 @@ export async function checkPersistentModes(
): Promise<PersistentModeResult> {
const workingDir = resolveToWorktreeRoot(directory);
// Hard bypass invariants: never enforce stop continuation under any of these
// environment-level kill switches. bridge.ts also guards DISABLE_OMC and
// OMC_SKIP_HOOKS at hook-entry, but we re-check here so direct callers and
// nested helpers (team workers, tests) observe the same contract.
if (
process.env.DISABLE_OMC === '1' ||
process.env.DISABLE_OMC === 'true' ||
process.env.OMC_TEAM_WORKER
) {
return { shouldBlock: false, message: '', mode: 'none' };
}
const skipHooks = (process.env.OMC_SKIP_HOOKS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (skipHooks.includes('persistent-mode') || skipHooks.includes('stop-continuation')) {
return { shouldBlock: false, message: '', mode: 'none' };
}
// Best-effort: prune expired tombstones so stale completion markers do not
// linger past their TTL and mask a fresh invocation. Never let a prune
// failure interfere with stop enforcement.
try {
const { readSkillActiveStateNormalized, pruneExpiredWorkflowSkillTombstones, writeSkillActiveStateCopies } =
await import('../skill-state/index.js');
const current = readSkillActiveStateNormalized(workingDir, sessionId);
const pruned = pruneExpiredWorkflowSkillTombstones(current);
if (pruned !== current) {
writeSkillActiveStateCopies(workingDir, pruned, sessionId);
}
} catch {
// Skill-state module unavailable or ledger unreadable — continue with
// legacy priority enforcement.
}
// CRITICAL: Never block context-limit/critical-context stops.
// Blocking these causes a deadlock where Claude Code cannot compact or exit.
// See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213
@@ -1635,54 +1670,109 @@ export async function checkPersistentModes(
const todoResult = await checkIncompleteTodos(sessionId, workingDir, stopContext);
const hasIncompleteTodos = todoResult.count > 0;
// Priority 1: Ralph (explicit loop mode)
const ralphResult = await checkRalphLoop(sessionId, workingDir, cancelInProgress);
if (ralphResult) {
return ralphResult;
// Consult the workflow ledger ONCE before direct mode-priority shortcuts.
// `resolveAuthoritativeWorkflowSkill()` returns the root of the live chain
// (autopilot in `autopilot → ralph`), so stop enforcement bubbles up to the
// live parent rather than the child currently executing beneath it.
// Tombstoned slots are tracked separately so stale mode files from crashed
// sessions don't re-arm priority checks until TTL prune or fresh activation.
const tombstonedWorkflowModes = new Set<string>();
let workflowAuthority: string | null = null;
try {
const { readSkillActiveStateNormalized, resolveAuthoritativeWorkflowSkill } =
await import('../skill-state/index.js');
const ledger = readSkillActiveStateNormalized(workingDir, sessionId);
const authority = resolveAuthoritativeWorkflowSkill(ledger);
workflowAuthority = authority?.skill_name ?? null;
for (const [name, slot] of Object.entries(ledger.active_skills)) {
if (slot.completed_at) tombstonedWorkflowModes.add(name);
}
} catch {
// Ledger unavailable — fall back to legacy mode-file detection.
}
// Priority 1.5: Autopilot (full orchestration mode - higher than ultrawork, lower than ralph)
if (isAutopilotActive(workingDir, sessionId)) {
const autopilotResult = await checkAutopilot(sessionId, workingDir);
if (autopilotResult?.shouldBlock) {
return {
shouldBlock: true,
message: autopilotResult.message,
mode: 'autopilot',
metadata: {
iteration: autopilotResult.metadata?.iteration,
maxIterations: autopilotResult.metadata?.maxIterations,
phase: autopilotResult.phase,
tasksCompleted: autopilotResult.metadata?.tasksCompleted,
tasksTotal: autopilotResult.metadata?.tasksTotal,
toolError: autopilotResult.metadata?.toolError
}
};
// Authority-first ordering for nested workflow runs.
//
// `resolveAuthoritativeWorkflowSkill()` returns the root of the live chain.
// In `autopilot → ralph`, autopilot is the authoritative parent while ralph
// runs beneath it — stop enforcement must resolve to the live parent so its
// iteration accounting keeps advancing. The legacy ordering (ralph > autopilot)
// still applies whenever the ledger is silent or authority already is ralph.
const autopilotPriorityFirst = workflowAuthority === 'autopilot';
const runAutopilotPriority = async (): Promise<PersistentModeResult | null> => {
if (
tombstonedWorkflowModes.has('autopilot') ||
!isAutopilotActive(workingDir, sessionId)
) {
return null;
}
const autopilotResult = await checkAutopilot(sessionId, workingDir);
if (!autopilotResult?.shouldBlock) return null;
return {
shouldBlock: true,
message: autopilotResult.message,
mode: 'autopilot',
metadata: {
iteration: autopilotResult.metadata?.iteration,
maxIterations: autopilotResult.metadata?.maxIterations,
phase: autopilotResult.phase,
tasksCompleted: autopilotResult.metadata?.tasksCompleted,
tasksTotal: autopilotResult.metadata?.tasksTotal,
toolError: autopilotResult.metadata?.toolError,
},
};
};
const runRalphPriority = async (): Promise<PersistentModeResult | null> => {
// Skip when the ralph workflow slot is tombstoned — a stale `ralph-state.json`
// from a crashed session must not block a fresh invocation.
if (tombstonedWorkflowModes.has('ralph')) return null;
return checkRalphLoop(sessionId, workingDir, cancelInProgress);
};
if (autopilotPriorityFirst) {
const autopilotResult = await runAutopilotPriority();
if (autopilotResult) return autopilotResult;
const ralphResult = await runRalphPriority();
if (ralphResult) return ralphResult;
} else {
const ralphResult = await runRalphPriority();
if (ralphResult) return ralphResult;
const autopilotResult = await runAutopilotPriority();
if (autopilotResult) return autopilotResult;
}
// Priority 1.7: Ralplan (standalone consensus planning)
// Ralplan consensus loops (Planner/Architect/Critic) need hard-blocking.
// When ralplan runs under ralph, checkRalphLoop() handles it (Priority 1).
// Return ANY non-null result (including circuit breaker shouldBlock=false with message).
const ralplanResult = await checkRalplan(sessionId, workingDir, cancelInProgress);
if (ralplanResult) {
return ralplanResult;
// Suppressed when the ralplan slot is tombstoned so noisy re-handoff stops
// on completion until the tombstone TTL expires or a fresh slot reopens.
if (!tombstonedWorkflowModes.has('ralplan')) {
const ralplanResult = await checkRalplan(sessionId, workingDir, cancelInProgress);
if (ralplanResult) {
return ralplanResult;
}
}
// Priority 1.8: Team Pipeline (standalone team mode)
// When team runs without ralph, this provides stop-hook blocking.
// When team runs with ralph, checkRalphLoop() handles it (Priority 1).
// Return ANY non-null result (including circuit breaker shouldBlock=false with message).
const teamResult = await checkTeamPipeline(sessionId, workingDir, cancelInProgress);
if (teamResult) {
return teamResult;
if (!tombstonedWorkflowModes.has('team')) {
const teamResult = await checkTeamPipeline(sessionId, workingDir, cancelInProgress);
if (teamResult) {
return teamResult;
}
}
// Priority 2: Ultrawork Mode (performance mode with persistence)
const ultraworkResult = await checkUltrawork(sessionId, workingDir, hasIncompleteTodos, cancelInProgress);
if (ultraworkResult) {
return ultraworkResult;
if (!tombstonedWorkflowModes.has('ultrawork')) {
const ultraworkResult = await checkUltrawork(sessionId, workingDir, hasIncompleteTodos, cancelInProgress);
if (ultraworkResult) {
return ultraworkResult;
}
}
// Priority 3: Skill Active State (issue #1033)

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
@@ -11,7 +11,18 @@ import {
clearSkillActiveState,
isSkillStateStale,
checkSkillActiveState,
readSkillActiveStateNormalized,
writeSkillActiveStateCopies,
upsertWorkflowSkillSlot,
markWorkflowSkillCompleted,
clearWorkflowSkillSlot,
pruneExpiredWorkflowSkillTombstones,
resolveAuthoritativeWorkflowSkill,
emptySkillActiveStateV2,
WORKFLOW_TOMBSTONE_TTL_MS,
type SkillActiveState,
type SkillActiveStateV2,
type ActiveSkillSlot,
} from '../index.js';
function makeTempDir(): string {
@@ -547,4 +558,602 @@ describe('skill-state', () => {
expect(finalCheck.shouldBlock).toBe(false);
});
});
// -----------------------------------------------------------------------
// writeSkillActiveStateCopies — dual-write invariant (spec a/b)
// -----------------------------------------------------------------------
describe('writeSkillActiveStateCopies — dual-write invariant (spec a/b)', () => {
const rootFilePath = (dir: string) => join(dir, '.omc', 'state', 'skill-active-state.json');
const sessionFilePath = (dir: string, sid: string) =>
join(dir, '.omc', 'state', 'sessions', sid, 'skill-active-state.json');
it('writes both root and session copies on seed', () => {
const sessionId = 'dwc-seed-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, state, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(true);
});
it('both copies contain identical slot content after seed', () => {
const sessionId = 'dwc-parity-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'autopilot', {
session_id: sessionId,
mode_state_path: 'autopilot-state.json',
initialized_mode: 'autopilot',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, state, sessionId);
const root = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8')) as SkillActiveStateV2;
const session = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8')) as SkillActiveStateV2;
expect(root.active_skills['autopilot']).toBeDefined();
expect(session.active_skills['autopilot']).toBeDefined();
expect(root.active_skills['autopilot']?.session_id).toBe(sessionId);
expect(session.active_skills['autopilot']?.session_id).toBe(sessionId);
});
it('writes only root copy when sessionId is omitted', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 'anon',
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: '',
});
writeSkillActiveStateCopies(tempDir, state);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(join(tempDir, '.omc', 'state', 'sessions'))).toBe(false);
});
it('both copies reflect tombstone after markWorkflowSkillCompleted (spec b)', () => {
const sessionId = 'dwc-tomb-01';
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, seeded, sessionId);
const tombstoneTime = '2026-04-17T10:00:00.000Z';
const tombstoned = markWorkflowSkillCompleted(seeded, 'ralph', tombstoneTime);
writeSkillActiveStateCopies(tempDir, tombstoned, sessionId);
const root = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8')) as SkillActiveStateV2;
const session = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8')) as SkillActiveStateV2;
expect(root.active_skills['ralph']?.completed_at).toBe(tombstoneTime);
expect(session.active_skills['ralph']?.completed_at).toBe(tombstoneTime);
});
it('removes both files when all slots cleared (spec b cancel)', () => {
const sessionId = 'dwc-cancel-01';
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
writeSkillActiveStateCopies(tempDir, seeded, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(true);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(true);
const cleared = clearWorkflowSkillSlot(seeded, 'ralph');
writeSkillActiveStateCopies(tempDir, cleared, sessionId);
expect(existsSync(rootFilePath(tempDir))).toBe(false);
expect(existsSync(sessionFilePath(tempDir, sessionId))).toBe(false);
});
it('returns true on successful dual-write', () => {
const sessionId = 'dwc-ok-01';
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ultrawork', {
session_id: sessionId,
mode_state_path: 'ultrawork-state.json',
initialized_mode: 'ultrawork',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
});
const result = writeSkillActiveStateCopies(tempDir, state, sessionId);
expect(result).toBe(true);
});
});
// -----------------------------------------------------------------------
// readSkillActiveStateNormalized — v1 scalar + v2 normalization (spec a)
// -----------------------------------------------------------------------
describe('readSkillActiveStateNormalized — normalization and session authority', () => {
it('returns empty v2 when no files exist', () => {
const state = readSkillActiveStateNormalized(tempDir, 'no-session');
expect(state.version).toBe(2);
expect(Object.keys(state.active_skills)).toHaveLength(0);
});
it('normalizes v1 scalar payload into support_skill branch', () => {
const stateDir = join(tempDir, '.omc', 'state');
mkdirSync(stateDir, { recursive: true });
const v1 = {
active: true,
skill_name: 'plan',
session_id: 'v1-sess',
started_at: new Date().toISOString(),
last_checked_at: new Date().toISOString(),
reinforcement_count: 0,
max_reinforcements: 5,
stale_ttl_ms: 15 * 60 * 1000,
};
writeFileSync(join(stateDir, 'skill-active-state.json'), JSON.stringify(v1, null, 2));
const normalized = readSkillActiveStateNormalized(tempDir);
expect(normalized.version).toBe(2);
expect(normalized.support_skill?.skill_name).toBe('plan');
expect(Object.keys(normalized.active_skills)).toHaveLength(0);
});
it('session copy is authoritative for session-local reads', () => {
const sessionId = 'norm-auth-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const rootState: SkillActiveStateV2 = {
version: 2,
active_skills: {
'autopilot': {
skill_name: 'autopilot',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: 'other-session',
mode_state_path: '',
initialized_mode: 'autopilot',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
const sessionState: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: '',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
writeFileSync(join(rootDir, 'skill-active-state.json'), JSON.stringify(rootState));
writeFileSync(join(sessionDir, 'skill-active-state.json'), JSON.stringify(sessionState));
const result = readSkillActiveStateNormalized(tempDir, sessionId);
expect(result.active_skills['ralph']).toBeDefined();
expect(result.active_skills['autopilot']).toBeUndefined();
});
it('returns empty state when sessionId provided but no session copy exists (no cross-session leak)', () => {
const rootDir = join(tempDir, '.omc', 'state');
mkdirSync(rootDir, { recursive: true });
const rootState: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph',
started_at: '2026-01-01T00:00:00Z',
completed_at: null,
session_id: 'root-only',
mode_state_path: '',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
},
},
};
writeFileSync(join(rootDir, 'skill-active-state.json'), JSON.stringify(rootState));
const result = readSkillActiveStateNormalized(tempDir, 'different-session');
expect(Object.keys(result.active_skills)).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// pruneExpiredWorkflowSkillTombstones — TTL sweep (spec c)
// -----------------------------------------------------------------------
describe('pruneExpiredWorkflowSkillTombstones — TTL sweep (spec c)', () => {
const makeSlot = (skillName: string, completedAt?: string | null): ActiveSkillSlot => ({
skill_name: skillName,
started_at: '2026-04-17T00:00:00.000Z',
completed_at: completedAt ?? null,
session_id: 'prune-session',
mode_state_path: `${skillName}-state.json`,
initialized_mode: skillName,
initialized_state_path: '',
initialized_session_state_path: '',
});
it('removes tombstoned slots past TTL', () => {
const past = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); // 25h ago
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph', past) },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state, WORKFLOW_TOMBSTONE_TTL_MS);
expect(pruned.active_skills['ralph']).toBeUndefined();
});
it('keeps tombstoned slots within TTL', () => {
const recent = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); // 1h ago
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph', recent) },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state, WORKFLOW_TOMBSTONE_TTL_MS);
expect(pruned.active_skills['ralph']).toBeDefined();
});
it('never removes live (non-tombstoned) slots', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'autopilot': makeSlot('autopilot') },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['autopilot']).toBeDefined();
});
it('prunes only stale tombstones, keeps fresh tombstones and live slots', () => {
const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
const fresh = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': makeSlot('ralph', old),
'autopilot': makeSlot('autopilot', fresh),
'ultrawork': makeSlot('ultrawork'),
},
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['ralph']).toBeUndefined();
expect(pruned.active_skills['autopilot']).toBeDefined();
expect(pruned.active_skills['ultrawork']).toBeDefined();
});
it('returns same reference when nothing changed', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'autopilot': makeSlot('autopilot') },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned).toBe(state);
});
it('keeps slot with malformed completed_at defensively', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'ralph': { ...makeSlot('ralph'), completed_at: 'not-a-date' } },
};
const pruned = pruneExpiredWorkflowSkillTombstones(state);
expect(pruned.active_skills['ralph']).toBeDefined();
});
it('WORKFLOW_TOMBSTONE_TTL_MS equals 24 hours', () => {
expect(WORKFLOW_TOMBSTONE_TTL_MS).toBe(24 * 60 * 60 * 1000);
});
});
// -----------------------------------------------------------------------
// resolveAuthoritativeWorkflowSkill — nested lineage (spec f)
// -----------------------------------------------------------------------
describe('resolveAuthoritativeWorkflowSkill — nested lineage (spec f)', () => {
const makeSlot = (skillName: string, opts: Partial<ActiveSkillSlot> = {}): ActiveSkillSlot => ({
skill_name: skillName,
started_at: new Date().toISOString(),
completed_at: null,
session_id: 'nest-session',
mode_state_path: `${skillName}-state.json`,
initialized_mode: skillName,
initialized_state_path: '',
initialized_session_state_path: '',
...opts,
});
it('returns null when no slots', () => {
expect(resolveAuthoritativeWorkflowSkill(emptySkillActiveStateV2())).toBeNull();
});
it('returns null when all slots are tombstoned', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': makeSlot('ralph', { completed_at: '2026-04-17T00:00:00Z' }),
},
};
expect(resolveAuthoritativeWorkflowSkill(state)).toBeNull();
});
it('returns the single live slot', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: { 'ralph': makeSlot('ralph') },
};
expect(resolveAuthoritativeWorkflowSkill(state)?.skill_name).toBe('ralph');
});
it('returns autopilot (outer root) while ralph (child) is live beneath it', () => {
const autopilotStarted = new Date(Date.now() - 5000).toISOString();
const ralphStarted = new Date().toISOString();
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot', { started_at: autopilotStarted }),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot', started_at: ralphStarted }),
},
};
const result = resolveAuthoritativeWorkflowSkill(state);
expect(result?.skill_name).toBe('autopilot');
});
it('ralph tombstone does not affect autopilot; autopilot stays authoritative', () => {
const autopilotStarted = new Date(Date.now() - 5000).toISOString();
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot', { started_at: autopilotStarted }),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot', completed_at: '2026-04-17T00:00:00Z' }),
},
};
const result = resolveAuthoritativeWorkflowSkill(state);
expect(result?.skill_name).toBe('autopilot');
expect(result?.completed_at).toBeFalsy();
});
it('autopilot completed_at stays unset while ralph is active beneath it (spec f invariant)', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'autopilot': makeSlot('autopilot'),
'ralph': makeSlot('ralph', { parent_skill: 'autopilot' }),
},
};
expect(state.active_skills['autopilot']?.completed_at).toBeFalsy();
expect(resolveAuthoritativeWorkflowSkill(state)?.skill_name).toBe('autopilot');
});
});
// -----------------------------------------------------------------------
// Diverged-copy reconciliation (spec d)
// -----------------------------------------------------------------------
describe('diverged-copy reconciliation (spec d)', () => {
const rootFilePath = (dir: string) => join(dir, '.omc', 'state', 'skill-active-state.json');
const sessionFilePath = (dir: string, sid: string) =>
join(dir, '.omc', 'state', 'sessions', sid, 'skill-active-state.json');
it('session copy is authoritative when root and session copies diverge', () => {
const sessionId = 'drift-auth-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const baseSlot: ActiveSkillSlot = {
skill_name: 'ralph',
started_at: '2026-04-17T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
};
const staleRootState: SkillActiveStateV2 = { version: 2, active_skills: { 'ralph': baseSlot } };
const freshSessionState: SkillActiveStateV2 = {
version: 2,
active_skills: { 'ralph': { ...baseSlot, last_confirmed_at: '2026-04-17T01:00:00Z' } },
};
writeFileSync(rootFilePath(tempDir), JSON.stringify(staleRootState));
writeFileSync(sessionFilePath(tempDir, sessionId), JSON.stringify(freshSessionState));
const result = readSkillActiveStateNormalized(tempDir, sessionId);
expect(result.active_skills['ralph']?.last_confirmed_at).toBe('2026-04-17T01:00:00Z');
});
it('next writeSkillActiveStateCopies re-syncs diverged copies', () => {
const sessionId = 'drift-resync-01';
const rootDir = join(tempDir, '.omc', 'state');
const sessionDir = join(rootDir, 'sessions', sessionId);
mkdirSync(rootDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const baseSlot: ActiveSkillSlot = {
skill_name: 'ralph',
started_at: '2026-04-17T00:00:00Z',
completed_at: null,
session_id: sessionId,
mode_state_path: 'ralph-state.json',
initialized_mode: 'ralph',
initialized_state_path: rootFilePath(tempDir),
initialized_session_state_path: sessionFilePath(tempDir, sessionId),
};
writeFileSync(rootFilePath(tempDir), JSON.stringify({ version: 2, active_skills: { 'ralph': baseSlot } }));
writeFileSync(sessionFilePath(tempDir, sessionId), JSON.stringify({
version: 2,
active_skills: { 'ralph': { ...baseSlot, last_confirmed_at: '2026-04-17T01:00:00Z' } },
}));
// Next mutation: tombstone via session-authoritative read → dual-write reconciles
const current = readSkillActiveStateNormalized(tempDir, sessionId);
const tombstoned = markWorkflowSkillCompleted(current, 'ralph');
writeSkillActiveStateCopies(tempDir, tombstoned, sessionId);
const rootAfter = JSON.parse(readFileSync(rootFilePath(tempDir), 'utf-8')) as SkillActiveStateV2;
const sessionAfter = JSON.parse(readFileSync(sessionFilePath(tempDir, sessionId), 'utf-8')) as SkillActiveStateV2;
expect(rootAfter.active_skills['ralph']?.completed_at).toBeTruthy();
expect(sessionAfter.active_skills['ralph']?.completed_at).toBeTruthy();
});
});
// -----------------------------------------------------------------------
// Pure workflow-slot helpers — unit tests
// -----------------------------------------------------------------------
describe('upsertWorkflowSkillSlot — pure helper', () => {
it('creates a new slot with provided fields', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(state.active_skills['ralph']?.skill_name).toBe('ralph');
expect(state.active_skills['ralph']?.session_id).toBe('s1');
});
it('preserves started_at on re-upsert (idempotent seed)', () => {
const seeded = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'ralph', {
session_id: 's1',
started_at: '2026-01-01T00:00:00Z',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
const confirmed = upsertWorkflowSkillSlot(seeded, 'ralph', {
last_confirmed_at: '2026-04-17T00:00:00Z',
});
expect(confirmed.active_skills['ralph']?.started_at).toBe('2026-01-01T00:00:00Z');
expect(confirmed.active_skills['ralph']?.last_confirmed_at).toBe('2026-04-17T00:00:00Z');
});
it('strips oh-my-claudecode: prefix from skill name', () => {
const state = upsertWorkflowSkillSlot(emptySkillActiveStateV2(), 'oh-my-claudecode:ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(state.active_skills['ralph']).toBeDefined();
expect(state.active_skills['oh-my-claudecode:ralph']).toBeUndefined();
});
it('does not mutate the original state object', () => {
const original = emptySkillActiveStateV2();
upsertWorkflowSkillSlot(original, 'ralph', {
session_id: 's1',
mode_state_path: 'r.json',
initialized_mode: 'ralph',
initialized_state_path: '',
initialized_session_state_path: '',
});
expect(Object.keys(original.active_skills)).toHaveLength(0);
});
});
describe('markWorkflowSkillCompleted — pure helper', () => {
it('sets completed_at to provided timestamp', () => {
const ts = '2026-04-17T12:00:00.000Z';
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const tombstoned = markWorkflowSkillCompleted(state, 'ralph', ts);
expect(tombstoned.active_skills['ralph']?.completed_at).toBe(ts);
});
it('returns state unchanged when slot is absent (idempotent)', () => {
const state = emptySkillActiveStateV2();
const result = markWorkflowSkillCompleted(state, 'ralph');
expect(result).toBe(state);
});
it('does not tombstone sibling slots', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
'autopilot': {
skill_name: 'autopilot', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'autopilot',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const tombstoned = markWorkflowSkillCompleted(state, 'ralph');
expect(tombstoned.active_skills['autopilot']?.completed_at).toBeFalsy();
});
});
describe('clearWorkflowSkillSlot — pure helper', () => {
it('removes the slot entirely', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const cleared = clearWorkflowSkillSlot(state, 'ralph');
expect(cleared.active_skills['ralph']).toBeUndefined();
});
it('is idempotent when slot is absent', () => {
const state = emptySkillActiveStateV2();
const result = clearWorkflowSkillSlot(state, 'ralph');
expect(result).toBe(state);
});
it('does not remove sibling slots', () => {
const state: SkillActiveStateV2 = {
version: 2,
active_skills: {
'ralph': {
skill_name: 'ralph', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'ralph',
initialized_state_path: '', initialized_session_state_path: '',
},
'autopilot': {
skill_name: 'autopilot', started_at: '2026-04-17T00:00:00Z', completed_at: null,
session_id: 's1', mode_state_path: '', initialized_mode: 'autopilot',
initialized_state_path: '', initialized_session_state_path: '',
},
},
};
const cleared = clearWorkflowSkillSlot(state, 'ralph');
expect(cleared.active_skills['autopilot']).toBeDefined();
});
});
});

View File

@@ -1,26 +1,81 @@
/**
* Skill Active State Management
* Skill Active State Management (v2 mixed schema)
*
* Tracks when a skill is actively executing so the persistent-mode Stop hook
* can prevent premature session termination.
* `skill-active-state.json` is a dual-copy workflow ledger:
*
* Skills like plan, external-context, deepinit etc. don't write mode state
* files (ralph-state.json, etc.), so the Stop hook previously had no way to
* know they were running.
* {
* "version": 2,
* "active_skills": { // workflow-slot ledger
* "<canonical workflow skill>": {
* "skill_name": ...,
* "started_at": ...,
* "completed_at": ..., // soft tombstone
* "parent_skill": ..., // lineage for nested runs
* "session_id": ...,
* "mode_state_path": ...,
* "initialized_mode": ...,
* "initialized_state_path": ...,
* "initialized_session_state_path": ...
* }
* },
* "support_skill": { // legacy-compatible branch
* "active": true, "skill_name": "plan", ...
* }
* }
*
* This module provides:
* 1. A protection level registry for all skills (none/light/medium/heavy)
* 2. Read/write/clear functions for skill-active-state.json
* 3. A check function for the Stop hook to determine if blocking is needed
*
* Fix for: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1033
* HARD INVARIANTS:
* 1. `writeSkillActiveStateCopies()` is the only helper allowed to persist
* workflow-slot state. Every workflow-slot write, confirm, tombstone, TTL
* pruning, and hard-clear must update BOTH
* - `.omc/state/skill-active-state.json`
* - `.omc/state/sessions/{sessionId}/skill-active-state.json`
* together through this single helper.
* 2. Support-skill writes go through the same helper so the shared file
* never drops the `active_skills` branch.
* 3. The session copy is authoritative for session-local reads; the root
* copy is authoritative for cross-session aggregation.
*/
import { writeModeState, readModeState, clearModeStateFile } from '../../lib/mode-state-io.js';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import {
resolveStatePath,
resolveSessionStatePath,
} from '../../lib/worktree-paths.js';
import { atomicWriteJsonSync } from '../../lib/atomic-write.js';
import { readTrackingState, getStaleAgents } from '../subagent-tracker/index.js';
// ---------------------------------------------------------------------------
// Types
// Constants
// ---------------------------------------------------------------------------
export const SKILL_ACTIVE_STATE_MODE = 'skill-active';
export const SKILL_ACTIVE_STATE_FILE = 'skill-active-state.json';
export const WORKFLOW_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Canonical workflow skills — the only skills that get workflow slots.
* Non-workflow skills keep today's `light/medium/heavy` protection via the
* `support_skill` branch.
*/
export const CANONICAL_WORKFLOW_SKILLS = [
'autopilot',
'ralph',
'team',
'ultrawork',
'ultraqa',
'deep-interview',
'ralplan',
'self-improve',
] as const;
export type CanonicalWorkflowSkill = typeof CANONICAL_WORKFLOW_SKILLS[number];
export function isCanonicalWorkflowSkill(skillName: string): skillName is CanonicalWorkflowSkill {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
return (CANONICAL_WORKFLOW_SKILLS as readonly string[]).includes(normalized);
}
// ---------------------------------------------------------------------------
// Support-skill protection (preserves v1 behavior)
// ---------------------------------------------------------------------------
export type SkillProtectionLevel = 'none' | 'light' | 'medium' | 'heavy';
@@ -32,53 +87,31 @@ export interface SkillStateConfig {
staleTtlMs: number;
}
export interface SkillActiveState {
active: boolean;
skill_name: string;
session_id?: string;
started_at: string;
last_checked_at: string;
reinforcement_count: number;
max_reinforcements: number;
stale_ttl_ms: number;
}
// ---------------------------------------------------------------------------
// Protection configuration per level
// ---------------------------------------------------------------------------
const PROTECTION_CONFIGS: Record<SkillProtectionLevel, SkillStateConfig> = {
none: { maxReinforcements: 0, staleTtlMs: 0 },
light: { maxReinforcements: 3, staleTtlMs: 5 * 60 * 1000 }, // 5 min
medium: { maxReinforcements: 5, staleTtlMs: 15 * 60 * 1000 }, // 15 min
heavy: { maxReinforcements: 10, staleTtlMs: 30 * 60 * 1000 }, // 30 min
medium: { maxReinforcements: 5, staleTtlMs: 15 * 60 * 1000 }, // 15 min
heavy: { maxReinforcements: 10, staleTtlMs: 30 * 60 * 1000 }, // 30 min
};
// ---------------------------------------------------------------------------
// Skill → protection level mapping
// ---------------------------------------------------------------------------
/**
* Maps each skill name to its protection level.
* Maps each skill name to its support-skill protection level.
*
* - 'none': Already has dedicated mode state (ralph, autopilot, etc.) or is
* instant/read-only (trace, hud, omc-help, etc.)
* - 'light': Quick utility skills
* - 'medium': Review/planning skills that run multiple agents
* - 'heavy': Long-running skills (deepinit, omc-setup)
*
* IMPORTANT: When adding a new OMC skill, register it here with the
* appropriate protection level. Unregistered skills default to 'none'
* (no stop-hook protection) to avoid blocking external plugin skills.
* Workflow skills (autopilot, ralph, ultrawork, team, ultraqa, ralplan,
* deep-interview, self-improve) have dedicated mode state and workflow slots,
* so their support-skill protection is 'none'. They flow through the
* `active_skills` branch instead.
*/
const SKILL_PROTECTION: Record<string, SkillProtectionLevel> = {
// === Already have mode state → no additional protection ===
// === Canonical workflow skills — bypass support-skill protection; flow through the workflow-slot path ===
autopilot: 'none',
ralph: 'none',
ultrawork: 'none',
team: 'none',
'omc-teams': 'none',
ultraqa: 'none',
ralplan: 'none',
'self-improve': 'none',
cancel: 'none',
// === Instant / read-only → no protection needed ===
@@ -97,7 +130,6 @@ const SKILL_PROTECTION: Record<string, SkillProtectionLevel> = {
// === Medium protection (review/planning, 5 reinforcements) ===
'omc-plan': 'medium',
plan: 'medium',
ralplan: 'none', // Has first-class checkRalplan() enforcement; no skill-active needed
'deep-interview': 'heavy',
review: 'medium',
'external-context': 'medium',
@@ -105,10 +137,10 @@ const SKILL_PROTECTION: Record<string, SkillProtectionLevel> = {
sciomc: 'medium',
learner: 'medium',
'omc-setup': 'medium',
setup: 'medium', // alias for omc-setup
setup: 'medium',
'mcp-setup': 'medium',
'project-session-manager': 'medium',
psm: 'medium', // alias for project-session-manager
psm: 'medium',
'writer-memory': 'medium',
'ralph-init': 'medium',
release: 'medium',
@@ -116,31 +148,9 @@ const SKILL_PROTECTION: Record<string, SkillProtectionLevel> = {
// === Heavy protection (long-running, 10 reinforcements) ===
deepinit: 'heavy',
'self-improve': 'heavy',
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Get the protection level for a skill.
*
* Only skills explicitly registered in SKILL_PROTECTION receive stop-hook
* protection. Unregistered skills (including external plugin skills like
* Anthropic's example-skills, document-skills, superpowers, data, etc.)
* default to 'none' so the Stop hook does not block them.
*
* @param skillName - The normalized (prefix-stripped) skill name.
* @param rawSkillName - The original skill name as invoked (e.g., 'oh-my-claudecode:plan'
* or 'plan'). When provided, only skills invoked with the 'oh-my-claudecode:' prefix
* are eligible for protection. This prevents project custom skills (e.g., a user's
* `.claude/skills/plan/`) from being confused with OMC built-in skills of the same name.
* See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1581
*/
export function getSkillProtection(skillName: string, rawSkillName?: string): SkillProtectionLevel {
// When rawSkillName is provided, only apply protection to OMC-prefixed skills.
// Non-prefixed skills are project custom skills or other plugins — no protection.
if (rawSkillName != null && !rawSkillName.toLowerCase().startsWith('oh-my-claudecode:')) {
return 'none';
}
@@ -148,34 +158,461 @@ export function getSkillProtection(skillName: string, rawSkillName?: string): Sk
return SKILL_PROTECTION[normalized] ?? 'none';
}
/**
* Get the protection config for a skill.
*/
export function getSkillConfig(skillName: string, rawSkillName?: string): SkillStateConfig {
return PROTECTION_CONFIGS[getSkillProtection(skillName, rawSkillName)];
}
/**
* Read the current skill active state.
* Returns null if no state exists or state is invalid.
*/
export function readSkillActiveState(
directory: string,
sessionId?: string
): SkillActiveState | null {
const state = readModeState<SkillActiveState>('skill-active', directory, sessionId);
if (!state || typeof state.active !== 'boolean') {
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Legacy-compatible support-skill state shape (unchanged from v1). */
export interface SkillActiveState {
active: boolean;
skill_name: string;
session_id?: string;
started_at: string;
last_checked_at: string;
reinforcement_count: number;
max_reinforcements: number;
stale_ttl_ms: number;
}
/** A single workflow-slot entry keyed by canonical workflow skill name. */
export interface ActiveSkillSlot {
skill_name: string;
started_at: string;
/** Soft tombstone. `null`/undefined = live. ISO timestamp = tombstoned. */
completed_at?: string | null;
/** Last idempotent re-confirmation timestamp (post-tool). */
last_confirmed_at?: string;
/** Parent skill name for nested lineage (e.g. ralph under autopilot). */
parent_skill?: string | null;
session_id: string;
/** Absolute or relative path to the mode-specific state file. */
mode_state_path: string;
/** Mode to initialize alongside this slot (usually equals skill_name). */
initialized_mode: string;
/** Pointer to the root `skill-active-state.json` copy at write time. */
initialized_state_path: string;
/** Pointer to the session `skill-active-state.json` copy at write time. */
initialized_session_state_path: string;
/** Origin of the slot (e.g. 'prompt-submit', 'post-tool'). */
source?: string;
}
/** v2 mixed schema. */
export interface SkillActiveStateV2 {
version: 2;
active_skills: Record<string, ActiveSkillSlot>;
support_skill?: SkillActiveState | null;
}
export interface WriteSkillActiveStateCopiesOptions {
/**
* Override the root copy payload. Defaults to writing the same payload as
* the session copy. Pass `null` to explicitly delete the root copy while
* keeping the session copy.
*/
rootState?: SkillActiveStateV2 | null;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
export function emptySkillActiveStateV2(): SkillActiveStateV2 {
return { version: 2, active_skills: {} };
}
function isEmptyV2(state: SkillActiveStateV2): boolean {
return Object.keys(state.active_skills).length === 0 && !state.support_skill;
}
function readRawFromPath(path: string): unknown {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return null;
}
return state;
}
/**
* Write skill active state.
* Called when a skill is invoked via the Skill tool.
* Normalize any raw payload (v1 scalar, v2 mixed, or unknown) into v2. Legacy
* scalar state is folded into `support_skill` so support-skill data is never
* dropped during migration.
*/
function normalizeToV2(raw: unknown): SkillActiveStateV2 {
if (!raw || typeof raw !== 'object') {
return emptySkillActiveStateV2();
}
const obj = raw as Record<string, unknown>;
// Strip `_meta` envelope if present (added by atomic writes).
const { _meta: _meta, ...rest } = obj;
void _meta;
const state = rest as Record<string, unknown>;
const looksV2 =
state.version === 2 || 'active_skills' in state || 'support_skill' in state;
if (looksV2) {
const active_skills: Record<string, ActiveSkillSlot> = {};
const raw_slots = state.active_skills;
if (raw_slots && typeof raw_slots === 'object' && !Array.isArray(raw_slots)) {
for (const [name, slot] of Object.entries(raw_slots as Record<string, unknown>)) {
if (slot && typeof slot === 'object') {
active_skills[name] = slot as ActiveSkillSlot;
}
}
}
const support_skill =
state.support_skill && typeof state.support_skill === 'object'
? (state.support_skill as SkillActiveState)
: null;
return { version: 2, active_skills, support_skill };
}
// Legacy scalar shape → fold into support_skill.
if (typeof state.active === 'boolean' && typeof state.skill_name === 'string') {
return {
version: 2,
active_skills: {},
support_skill: state as unknown as SkillActiveState,
};
}
return emptySkillActiveStateV2();
}
// ---------------------------------------------------------------------------
// Pure workflow-slot helpers
// ---------------------------------------------------------------------------
/** Upsert (create or update) a workflow slot on a v2 state. Pure. */
export function upsertWorkflowSkillSlot(
state: SkillActiveStateV2,
skillName: string,
slotData: Partial<ActiveSkillSlot> = {},
): SkillActiveStateV2 {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const existing = state.active_skills[normalized];
const now = new Date().toISOString();
const base: ActiveSkillSlot = {
skill_name: normalized,
started_at: existing?.started_at ?? now,
completed_at: existing?.completed_at ?? null,
parent_skill: existing?.parent_skill ?? null,
session_id: existing?.session_id ?? '',
mode_state_path: existing?.mode_state_path ?? '',
initialized_mode: existing?.initialized_mode ?? normalized,
initialized_state_path: existing?.initialized_state_path ?? '',
initialized_session_state_path: existing?.initialized_session_state_path ?? '',
};
if (existing?.last_confirmed_at !== undefined) {
base.last_confirmed_at = existing.last_confirmed_at;
}
if (existing?.source !== undefined) {
base.source = existing.source;
}
const next: ActiveSkillSlot = {
...base,
...slotData,
skill_name: normalized,
};
return {
...state,
active_skills: { ...state.active_skills, [normalized]: next },
};
}
/**
* Soft tombstone: set `completed_at` on an existing slot. Slot is retained
* until the TTL pruner removes it. Returns state unchanged when the slot is
* absent (idempotent).
*/
export function markWorkflowSkillCompleted(
state: SkillActiveStateV2,
skillName: string,
now: string = new Date().toISOString(),
): SkillActiveStateV2 {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const existing = state.active_skills[normalized];
if (!existing) return state;
const updated: ActiveSkillSlot = { ...existing, completed_at: now };
return {
...state,
active_skills: { ...state.active_skills, [normalized]: updated },
};
}
/** Hard-clear: remove a slot entirely (for explicit cancel). Pure. */
export function clearWorkflowSkillSlot(
state: SkillActiveStateV2,
skillName: string,
): SkillActiveStateV2 {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
if (!(normalized in state.active_skills)) return state;
const next: Record<string, ActiveSkillSlot> = { ...state.active_skills };
delete next[normalized];
return { ...state, active_skills: next };
}
/**
* TTL prune: remove tombstoned slots whose `completed_at + ttlMs < now`.
* Called on UserPromptSubmit. Pure.
*/
export function pruneExpiredWorkflowSkillTombstones(
state: SkillActiveStateV2,
ttlMs: number = WORKFLOW_TOMBSTONE_TTL_MS,
now: number = Date.now(),
): SkillActiveStateV2 {
const next: Record<string, ActiveSkillSlot> = {};
let changed = false;
for (const [name, slot] of Object.entries(state.active_skills)) {
if (!slot.completed_at) {
next[name] = slot;
continue;
}
const tombstonedAt = new Date(slot.completed_at).getTime();
if (!Number.isFinite(tombstonedAt)) {
// Malformed timestamp — keep defensively rather than silently drop.
next[name] = slot;
continue;
}
if (now - tombstonedAt < ttlMs) {
next[name] = slot;
} else {
changed = true;
}
}
return changed ? { ...state, active_skills: next } : state;
}
/**
* Resolve the authoritative workflow slot for stop-hook and downstream
* consumers.
*
* @param rawSkillName - The original skill name as invoked, used to distinguish
* OMC built-in skills from project custom skills. See getSkillProtection().
* Rule: among live (non-tombstoned) slots, prefer those whose parent lineage
* is absent or itself tombstoned (roots of the live chain). Among those,
* return the newest by `started_at`. In nested `autopilot → ralph` flows this
* returns `autopilot` while ralph is still live beneath it, so stop-hook
* enforcement keeps reinforcing the outer loop.
*/
export function resolveAuthoritativeWorkflowSkill(
state: SkillActiveStateV2,
): ActiveSkillSlot | null {
const live = Object.values(state.active_skills).filter((s) => !s.completed_at);
if (live.length === 0) return null;
const isLiveAncestor = (name: string | null | undefined): boolean => {
if (!name) return false;
const parent = state.active_skills[name];
return !!parent && !parent.completed_at;
};
const roots = live.filter((s) => !isLiveAncestor(s.parent_skill ?? null));
const pool = roots.length > 0 ? roots : live;
pool.sort((a, b) => {
const bt = new Date(b.started_at).getTime() || 0;
const at = new Date(a.started_at).getTime() || 0;
return bt - at;
});
return pool[0] ?? null;
}
/**
* Pure query: is the workflow slot for `skillName` live (non-tombstoned)?
* Returns false when no slot exists at all, so callers can distinguish
* "no ledger entry" from "tombstoned" via `isWorkflowSkillTombstoned`.
*/
export function isWorkflowSkillLive(
state: SkillActiveStateV2,
skillName: string,
): boolean {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const slot = state.active_skills[normalized];
return !!slot && !slot.completed_at;
}
/**
* Pure query: is the slot tombstoned (has `completed_at`) and not yet expired?
* Used by stop enforcement to suppress noisy re-handoff on completed workflows
* until TTL pruning removes the slot or a fresh invocation reactivates it.
*/
export function isWorkflowSkillTombstoned(
state: SkillActiveStateV2,
skillName: string,
ttlMs: number = WORKFLOW_TOMBSTONE_TTL_MS,
now: number = Date.now(),
): boolean {
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
const slot = state.active_skills[normalized];
if (!slot || !slot.completed_at) return false;
const tombstonedAt = new Date(slot.completed_at).getTime();
if (!Number.isFinite(tombstonedAt)) return true;
return now - tombstonedAt < ttlMs;
}
// ---------------------------------------------------------------------------
// Read / Write I/O
// ---------------------------------------------------------------------------
/**
* Read the v2 mixed-schema workflow ledger, normalizing legacy scalar state
* into `support_skill` without dropping support-skill data.
*
* When `sessionId` is provided, the session copy is authoritative for
* session-local reads. No fall-through to the root copy, to prevent
* cross-session leakage. When no session copy exists for the session, the
* ledger is treated as empty for that session's local reads.
*
* When `sessionId` is omitted (legacy/global path), the root copy is read.
*
* Logs a reconciliation warning when the session copy diverges from the root
* for slots belonging to the same session. The next mutation through
* `writeSkillActiveStateCopies()` re-synchronizes both copies.
*/
export function readSkillActiveStateNormalized(
directory: string,
sessionId?: string,
): SkillActiveStateV2 {
const rootPath = resolveStatePath('skill-active', directory);
const sessionPath = sessionId
? resolveSessionStatePath('skill-active', sessionId, directory)
: null;
const sessionExists = !!(sessionPath && existsSync(sessionPath));
const rootExists = existsSync(rootPath);
const sessionV2 = sessionExists ? normalizeToV2(readRawFromPath(sessionPath!)) : null;
const rootV2 = rootExists ? normalizeToV2(readRawFromPath(rootPath)) : null;
// Divergence detection — best-effort; logged but non-fatal.
if (sessionV2 && rootV2 && sessionId) {
for (const [name, sessSlot] of Object.entries(sessionV2.active_skills)) {
const rootSlot = rootV2.active_skills[name];
if (!rootSlot) continue;
if (sessSlot.session_id !== sessionId) continue;
if (JSON.stringify(sessSlot) !== JSON.stringify(rootSlot)) {
// Non-fatal — next writeSkillActiveStateCopies() call will re-sync.
console.warn(
`[skill-active] copy drift detected for slot "${name}" in session ${sessionId}; ` +
'next mutation will reconcile via writeSkillActiveStateCopies().',
);
break;
}
}
}
// Session copy authoritative for session-local reads.
if (sessionV2) return sessionV2;
// sessionId provided but no session copy — do NOT fall back to root to
// prevent cross-session state leakage (#456).
if (sessionId) return emptySkillActiveStateV2();
// Legacy/global path: read root.
return rootV2 ?? emptySkillActiveStateV2();
}
/**
* THE ONLY HELPER allowed to persist workflow-slot state.
*
* Writes BOTH root `.omc/state/skill-active-state.json` AND session
* `.omc/state/sessions/{sessionId}/skill-active-state.json` together. When a
* resolved state is empty (no slots, no support_skill), the corresponding
* file is removed instead — the absence of a file is the canonical empty
* state.
*
* @returns true when all writes / deletes succeeded, false otherwise.
*/
export function writeSkillActiveStateCopies(
directory: string,
nextState: SkillActiveStateV2,
sessionId?: string,
options?: WriteSkillActiveStateCopiesOptions,
): boolean {
const rootPath = resolveStatePath('skill-active', directory);
const sessionPath = sessionId
? resolveSessionStatePath('skill-active', sessionId, directory)
: null;
// Root defaults to the same payload as session. Explicit `null` deletes root.
const rootState: SkillActiveStateV2 | null =
options?.rootState === undefined ? nextState : options.rootState;
const writeOrRemove = (filePath: string, payload: SkillActiveStateV2 | null): boolean => {
const shouldRemove = payload === null || isEmptyV2(payload);
if (shouldRemove) {
if (!existsSync(filePath)) return true;
try {
unlinkSync(filePath);
return true;
} catch {
return false;
}
}
try {
const envelope: Record<string, unknown> = {
...payload,
version: 2,
_meta: {
written_at: new Date().toISOString(),
mode: SKILL_ACTIVE_STATE_MODE,
...(sessionId ? { sessionId } : {}),
},
};
atomicWriteJsonSync(filePath, envelope);
return true;
} catch {
return false;
}
};
let ok = writeOrRemove(rootPath, rootState);
if (sessionPath) {
ok = writeOrRemove(sessionPath, nextState) && ok;
}
return ok;
}
// ---------------------------------------------------------------------------
// Legacy-compatible support-skill API (operates on the `support_skill` branch)
// ---------------------------------------------------------------------------
/**
* Read the support-skill state as a legacy scalar `SkillActiveState`.
*
* Returns null when no support_skill entry is present in the v2 ledger.
* Workflow slots are intentionally NOT exposed through this function —
* downstream workflow consumers should call `readSkillActiveStateNormalized()`
* and `resolveAuthoritativeWorkflowSkill()` instead.
*/
export function readSkillActiveState(
directory: string,
sessionId?: string,
): SkillActiveState | null {
const v2 = readSkillActiveStateNormalized(directory, sessionId);
const support = v2.support_skill;
if (!support || typeof support.active !== 'boolean') return null;
return support;
}
/**
* Write support-skill state. No-op for skills with 'none' protection.
*
* Preserves the `active_skills` workflow ledger — every write reads the full
* v2 state, updates only the `support_skill` branch, and re-writes both
* copies together via `writeSkillActiveStateCopies()`.
*
* @param rawSkillName - Original skill name as invoked. When provided without
* the `oh-my-claudecode:` prefix, protection returns 'none' to avoid
* confusion with user-defined project skills of the same name (#1581).
*/
export function writeSkillActiveState(
directory: string,
@@ -184,32 +621,22 @@ export function writeSkillActiveState(
rawSkillName?: string,
): SkillActiveState | null {
const protection = getSkillProtection(skillName, rawSkillName);
// Skills with 'none' protection don't need state tracking
if (protection === 'none') {
return null;
}
if (protection === 'none') return null;
const config = PROTECTION_CONFIGS[protection];
const now = new Date().toISOString();
const normalized = skillName.toLowerCase().replace(/^oh-my-claudecode:/, '');
// Nesting guard: when a skill (e.g. omc-setup) invokes a child skill
// (e.g. mcp-setup), the child must not overwrite the parent's active state.
// If a DIFFERENT skill is already active in this session, skip writing —
// the parent's stop-hook protection already covers the session.
// If the SAME skill is re-invoked, allow the overwrite (idempotent refresh).
//
// NOTE: This read-check-write sequence has a TOCTOU race condition
// (non-atomic), but this is acceptable because Claude Code sessions are
// single-threaded — only one tool call executes at a time within a session.
const existingState = readSkillActiveState(directory, sessionId);
if (existingState && existingState.active && existingState.skill_name !== normalized) {
// A different skill already owns the active state — do not overwrite.
const existingV2 = readSkillActiveStateNormalized(directory, sessionId);
const existing = existingV2.support_skill;
// Nesting guard: a DIFFERENT support skill already owns the slot — skip.
// Same skill re-invocation is allowed (idempotent refresh).
if (existing && existing.active && existing.skill_name !== normalized) {
return null;
}
const state: SkillActiveState = {
const support: SkillActiveState = {
active: true,
skill_name: normalized,
session_id: sessionId,
@@ -220,21 +647,20 @@ export function writeSkillActiveState(
stale_ttl_ms: config.staleTtlMs,
};
const success = writeModeState('skill-active', state as unknown as Record<string, unknown>, directory, sessionId);
return success ? state : null;
const nextV2: SkillActiveStateV2 = { ...existingV2, support_skill: support };
const ok = writeSkillActiveStateCopies(directory, nextV2, sessionId);
return ok ? support : null;
}
/**
* Clear skill active state.
* Called when a skill completes or is cancelled.
* Clear support-skill state while preserving workflow slots.
*/
export function clearSkillActiveState(directory: string, sessionId?: string): boolean {
return clearModeStateFile('skill-active', directory, sessionId);
const existingV2 = readSkillActiveStateNormalized(directory, sessionId);
const nextV2: SkillActiveStateV2 = { ...existingV2, support_skill: null };
return writeSkillActiveStateCopies(directory, nextV2, sessionId);
}
/**
* Check if the skill state is stale (exceeded its TTL).
*/
export function isSkillStateStale(state: SkillActiveState): boolean {
if (!state.active) return true;
@@ -253,14 +679,14 @@ export function isSkillStateStale(state: SkillActiveState): boolean {
}
/**
* Check skill active state for the Stop hook.
* Returns blocking decision with continuation message.
* Stop-hook integration for support skills.
*
* Called by checkPersistentModes() in the persistent-mode hook.
* Reinforcement increments go through `writeSkillActiveStateCopies()` so the
* workflow-slot ledger is never clobbered by support-skill writes.
*/
export function checkSkillActiveState(
directory: string,
sessionId?: string
sessionId?: string,
): { shouldBlock: boolean; message: string; skillName?: string } {
const state = readSkillActiveState(directory, sessionId);
@@ -286,44 +712,52 @@ export function checkSkillActiveState(
}
// Orchestrators are allowed to go idle while delegated work is still active.
// Do not consume a reinforcement here; the skill is still active and should
// resume enforcement only after the running subagents finish.
// Read tracking state and exclude stale agents (>5 min without updates)
// to prevent phantom "running" entries from blocking enforcement.
// Uses read-only filtering instead of cleanupStaleAgents() to avoid
// destructively marking legitimate long-running agents as failed.
const trackingState = readTrackingState(directory);
const staleIds = new Set(getStaleAgents(trackingState).map(a => a.agent_id));
const staleIds = new Set(getStaleAgents(trackingState).map((a) => a.agent_id));
const nonStaleRunning = trackingState.agents.filter(
a => a.status === 'running' && !staleIds.has(a.agent_id),
(a) => a.status === 'running' && !staleIds.has(a.agent_id),
);
if (nonStaleRunning.length > 0) {
// Reset reinforcement counter so accumulations during brief idle gaps
// don't cause premature skill-active clearance.
// Mirrors ralplan's writeStopBreaker(0) at persistent-mode/index.ts:984.
if (state.reinforcement_count > 0) {
state.reinforcement_count = 0;
state.last_checked_at = new Date().toISOString();
writeModeState('skill-active', state as unknown as Record<string, unknown>, directory, sessionId);
const resetSupport: SkillActiveState = {
...state,
reinforcement_count: 0,
last_checked_at: new Date().toISOString(),
};
const v2 = readSkillActiveStateNormalized(directory, sessionId);
writeSkillActiveStateCopies(
directory,
{ ...v2, support_skill: resetSupport },
sessionId,
);
}
return { shouldBlock: false, message: '', skillName: state.skill_name };
}
// Block the stop and increment reinforcement count
state.reinforcement_count += 1;
state.last_checked_at = new Date().toISOString();
const written = writeModeState('skill-active', state as unknown as Record<string, unknown>, directory, sessionId);
if (!written) {
// If we can't write, don't block
// Block the stop and increment reinforcement count.
const incremented: SkillActiveState = {
...state,
reinforcement_count: state.reinforcement_count + 1,
last_checked_at: new Date().toISOString(),
};
const v2 = readSkillActiveStateNormalized(directory, sessionId);
const ok = writeSkillActiveStateCopies(
directory,
{ ...v2, support_skill: incremented },
sessionId,
);
if (!ok) {
return { shouldBlock: false, message: '' };
}
const message = `[SKILL ACTIVE: ${state.skill_name}] The "${state.skill_name}" skill is still executing (reinforcement ${state.reinforcement_count}/${state.max_reinforcements}). Continue working on the skill's instructions. Do not stop until the skill completes its workflow.`;
const message =
`[SKILL ACTIVE: ${incremented.skill_name}] The "${incremented.skill_name}" skill is still executing ` +
`(reinforcement ${incremented.reinforcement_count}/${incremented.max_reinforcements}). ` +
`Continue working on the skill's instructions. Do not stop until the skill completes its workflow.`;
return {
shouldBlock: true,
message,
skillName: state.skill_name,
skillName: incremented.skill_name,
};
}

View File

@@ -14,6 +14,8 @@ export const MODE_NAMES = {
ULTRAWORK: 'ultrawork',
ULTRAQA: 'ultraqa',
RALPLAN: 'ralplan',
DEEP_INTERVIEW: 'deep-interview',
SELF_IMPROVE: 'self-improve',
} as const;
/**
@@ -40,6 +42,8 @@ export const ALL_MODE_NAMES: readonly ModeName[] = [
MODE_NAMES.ULTRAWORK,
MODE_NAMES.ULTRAQA,
MODE_NAMES.RALPLAN,
MODE_NAMES.DEEP_INTERVIEW,
MODE_NAMES.SELF_IMPROVE,
] as const;
/**
@@ -53,6 +57,8 @@ export const MODE_STATE_FILE_MAP: Readonly<Record<ModeName, string>> = {
[MODE_NAMES.ULTRAWORK]: 'ultrawork-state.json',
[MODE_NAMES.ULTRAQA]: 'ultraqa-state.json',
[MODE_NAMES.RALPLAN]: 'ralplan-state.json',
[MODE_NAMES.DEEP_INTERVIEW]: 'deep-interview-state.json',
[MODE_NAMES.SELF_IMPROVE]: 'self-improve-state.json',
};
/**
@@ -66,6 +72,8 @@ export const SESSION_END_MODE_STATE_FILES: readonly { file: string; mode: string
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAWORK], mode: MODE_NAMES.ULTRAWORK },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAQA], mode: MODE_NAMES.ULTRAQA },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPLAN], mode: MODE_NAMES.RALPLAN },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW], mode: MODE_NAMES.DEEP_INTERVIEW },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE], mode: MODE_NAMES.SELF_IMPROVE },
{ file: 'skill-active-state.json', mode: 'skill-active' },
];
@@ -77,4 +85,6 @@ export const SESSION_METRICS_MODE_FILES: readonly { file: string; mode: string }
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPH], mode: MODE_NAMES.RALPH },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.ULTRAWORK], mode: MODE_NAMES.ULTRAWORK },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.RALPLAN], mode: MODE_NAMES.RALPLAN },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.DEEP_INTERVIEW], mode: MODE_NAMES.DEEP_INTERVIEW },
{ file: MODE_STATE_FILE_MAP[MODE_NAMES.SELF_IMPROVE], mode: MODE_NAMES.SELF_IMPROVE },
];

View File

@@ -398,6 +398,21 @@ describe('state-tools', () => {
expect(result.content[0].text).toContain('deep-interview');
});
it('should include self-improve mode when self-improve state is active', async () => {
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1 },
workingDirectory: TEST_DIR,
});
const result = await stateListActiveTool.handler({
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('self-improve');
});
it('should include team in status output when team state is active', async () => {
await stateWriteTool.handler({
mode: 'team',
@@ -414,6 +429,206 @@ describe('state-tools', () => {
expect(result.content[0].text).toContain('Status: team');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('deep-interview and self-improve appear in all-mode status listing', async () => {
const result = await stateGetStatusTool.handler({
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('deep-interview');
expect(result.content[0].text).toContain('self-improve');
});
});
// -----------------------------------------------------------------------
// Registry parity: deep-interview and self-improve as first-class modes
// -----------------------------------------------------------------------
describe('deep-interview and self-improve registry parity (T1)', () => {
it('writes deep-interview state to session-scoped path via MODE_CONFIGS routing', async () => {
const sessionId = 'di-registry-write';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning', round: 3 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'deep-interview-state.json');
expect(existsSync(statePath)).toBe(true);
});
it('writes self-improve state to session-scoped path via MODE_CONFIGS routing', async () => {
const sessionId = 'si-registry-write';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1, best_score: 0.85 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'self-improve-state.json');
expect(existsSync(statePath)).toBe(true);
});
it('reads deep-interview state back from session-scoped path', async () => {
const sessionId = 'di-registry-read';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning', ambiguity_score: 0.34 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const result = await stateReadTool.handler({
mode: 'deep-interview',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('current_phase');
expect(result.content[0].text).toContain('ambiguity_score');
});
it('reads self-improve state back from session-scoped path', async () => {
const sessionId = 'si-registry-read';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 2, generation: 5 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const result = await stateReadTool.handler({
mode: 'self-improve',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('tournament_round');
expect(result.content[0].text).toContain('generation');
});
it('clears deep-interview state file for given session', async () => {
const sessionId = 'di-registry-clear';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'analysis' },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const clearResult = await stateClearTool.handler({
mode: 'deep-interview',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(clearResult.content[0].text).toMatch(/cleared|Successfully/i);
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'deep-interview-state.json');
expect(existsSync(statePath)).toBe(false);
});
it('clears self-improve state file for given session', async () => {
const sessionId = 'si-registry-clear';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 3 },
session_id: sessionId,
workingDirectory: TEST_DIR,
});
const clearResult = await stateClearTool.handler({
mode: 'self-improve',
session_id: sessionId,
workingDirectory: TEST_DIR,
});
expect(clearResult.content[0].text).toMatch(/cleared|Successfully/i);
const statePath = join(TEST_DIR, '.omc', 'state', 'sessions', sessionId, 'self-improve-state.json');
expect(existsSync(statePath)).toBe(false);
});
it('state_get_status reports self-improve as active when state file is present', async () => {
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 2 },
workingDirectory: TEST_DIR,
});
const result = await stateGetStatusTool.handler({
mode: 'self-improve',
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('Status: self-improve');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('state_get_status reports deep-interview as active when state file is present', async () => {
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'contrarian' },
workingDirectory: TEST_DIR,
});
const result = await stateGetStatusTool.handler({
mode: 'deep-interview',
workingDirectory: TEST_DIR,
});
expect(result.content[0].text).toContain('Status: deep-interview');
expect(result.content[0].text).toContain('**Active:** Yes');
});
it('deep-interview session isolation: write to session A does not appear under session B', async () => {
const sessionA = 'di-iso-a';
const sessionB = 'di-iso-b';
await stateWriteTool.handler({
mode: 'deep-interview',
active: true,
state: { current_phase: 'questioning' },
session_id: sessionA,
workingDirectory: TEST_DIR,
});
const resultB = await stateReadTool.handler({
mode: 'deep-interview',
session_id: sessionB,
workingDirectory: TEST_DIR,
});
expect(resultB.content[0].text).toContain('No state found');
});
it('self-improve session isolation: write to session A does not appear under session B', async () => {
const sessionA = 'si-iso-a';
const sessionB = 'si-iso-b';
await stateWriteTool.handler({
mode: 'self-improve',
active: true,
state: { tournament_round: 1 },
session_id: sessionA,
workingDirectory: TEST_DIR,
});
const resultB = await stateReadTool.handler({
mode: 'self-improve',
session_id: sessionB,
workingDirectory: TEST_DIR,
});
expect(resultB.content[0].text).toContain('No state found');
});
});
describe('state_get_status', () => {

View File

@@ -33,9 +33,11 @@ import {
} from '../hooks/mode-registry/index.js';
import { ToolDefinition } from './types.js';
// ExecutionMode from mode-registry (5 modes)
// Canonical execution modes from mode-registry (deep-interview and self-improve
// are first-class modes with dedicated MODE_CONFIGS entries; ralplan remains an
// extra state-only mode handled via the registry-fallback path).
const EXECUTION_MODES: [string, ...string[]] = [
'autopilot', 'team', 'ralph', 'ultrawork', 'ultraqa'
'autopilot', 'team', 'ralph', 'ultrawork', 'ultraqa', 'deep-interview', 'self-improve'
];
// Extended type for state tools - includes state-bearing modes outside mode-registry
@@ -43,11 +45,9 @@ const STATE_TOOL_MODES: [string, ...string[]] = [
...EXECUTION_MODES,
'ralplan',
'omc-teams',
'deep-interview',
'self-improve',
'skill-active'
];
const EXTRA_STATE_ONLY_MODES = ['ralplan', 'omc-teams', 'deep-interview', 'self-improve', 'skill-active'] as const;
const EXTRA_STATE_ONLY_MODES = ['ralplan', 'omc-teams', 'skill-active'] as const;
type StateToolMode = typeof STATE_TOOL_MODES[number];
const CANCEL_SIGNAL_TTL_MS = 30_000;