mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-21 05:12:30 +08:00
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:
6
dist/__tests__/auto-update.test.js
generated
vendored
6
dist/__tests__/auto-update.test.js
generated
vendored
@@ -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, '/');
|
||||
|
||||
2
dist/__tests__/auto-update.test.js.map
generated
vendored
2
dist/__tests__/auto-update.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
35
dist/__tests__/bedrock-lm-suffix-hook.test.js
generated
vendored
35
dist/__tests__/bedrock-lm-suffix-hook.test.js
generated
vendored
@@ -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]', () => {
|
||||
|
||||
2
dist/__tests__/bedrock-lm-suffix-hook.test.js.map
generated
vendored
2
dist/__tests__/bedrock-lm-suffix-hook.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/__tests__/bedrock-model-routing.test.js
generated
vendored
2
dist/__tests__/bedrock-model-routing.test.js
generated
vendored
@@ -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
18
dist/__tests__/hud/model.test.js
generated
vendored
@@ -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
2
dist/__tests__/npm-package-hook-surface.test.d.ts
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=npm-package-hook-surface.test.d.ts.map
|
||||
1
dist/__tests__/npm-package-hook-surface.test.d.ts.map
generated
vendored
Normal file
1
dist/__tests__/npm-package-hook-surface.test.d.ts.map
generated
vendored
Normal 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
85
dist/__tests__/npm-package-hook-surface.test.js
generated
vendored
Normal 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
1
dist/__tests__/npm-package-hook-surface.test.js.map
generated
vendored
Normal 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":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE/D,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;AACnC,MAAM,eAAe,GAAG,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC;AAClE,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,CAAC;AAoBnD,MAAM,eAAe,GAAG,yIAAyI,CAAC;AAClK,MAAM,gBAAgB,GAAG,6CAA6C,CAAC;AAEvE,SAAS,qBAAqB;IAC5B,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,eAAe,EAAE,OAAO,CAAC,CAAc,CAAC;IAClF,MAAM,OAAO,GAAG,IAAI,GAAG,CAAS,CAAC,iBAAiB,CAAC,CAAC,CAAC;IAErD,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC;QAC9D,KAAK,MAAM,YAAY,IAAI,UAAU,EAAE,CAAC;YACtC,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;gBAC5C,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC;gBACnC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBACxB,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;AAC7B,CAAC;AAED,SAAS,2BAA2B,CAAC,QAAgB,EAAE,SAAiB;IACtE,MAAM,QAAQ,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IAC/D,MAAM,UAAU,GAAG;QACjB,QAAQ;QACR,GAAG,QAAQ,MAAM;QACjB,GAAG,QAAQ,MAAM;QACjB,GAAG,QAAQ,KAAK;QAChB,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC;QAC3B,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC;QAC3B,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC;KAC3B,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,SAAS,CAAC,UAAU,CAAC,YAAY,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAChE,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,0BAA0B,CAAC,YAAoB,EAAE,YAAY,IAAI,GAAG,EAAU;IACrF,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;IACtD,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,0CAA0C,YAAY,EAAE,CAAC,CAAC;IAC5E,CAAC;IAED,MAAM,aAAa,GAAG,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IAC/E,IAAI,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC;QACjC,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAE7B,MAAM,OAAO,GAAG,YAAY,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IACpD,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC;QACtD,MAAM,QAAQ,GAAG,2BAA2B,CAAC,YAAY,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACrE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,SAAS;QACX,CAAC;QACD,0BAA0B,CAAC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,SAAS,CAAC,CAAC;IAC9F,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,cAAc;IACrB,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE;QAClE,GAAG,EAAE,YAAY;QACjB,QAAQ,EAAE,OAAO;KAClB,CAAC,CAAC;IAEH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAA0B,CAAC;IAC5D,OAAO,IAAI,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AACnE,CAAC;AAED,QAAQ,CAAC,qCAAqC,EAAE,GAAG,EAAE;IACnD,EAAE,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACnF,MAAM,aAAa,GAAG,IAAI,GAAG,CAAS,CAAC,kBAAkB,CAAC,CAAC,CAAC;QAE5D,KAAK,MAAM,YAAY,IAAI,qBAAqB,EAAE,EAAE,CAAC;YACnD,KAAK,MAAM,IAAI,IAAI,0BAA0B,CAAC,YAAY,CAAC,EAAE,CAAC;gBAC5D,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;QAED,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAEtD,MAAM,OAAO,GAAG,CAAC,GAAG,aAAa,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACjF,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
||||
8
dist/__tests__/types.test.js
generated
vendored
8
dist/__tests__/types.test.js
generated
vendored
@@ -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
2
dist/features/auto-update.d.ts.map
generated
vendored
@@ -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
54
dist/features/auto-update.js
generated
vendored
@@ -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
2
dist/features/auto-update.js.map
generated
vendored
File diff suppressed because one or more lines are too long
90
dist/hooks/__tests__/bridge-routing.test.js
generated
vendored
90
dist/hooks/__tests__/bridge-routing.test.js
generated
vendored
@@ -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',
|
||||
|
||||
2
dist/hooks/__tests__/bridge-routing.test.js.map
generated
vendored
2
dist/hooks/__tests__/bridge-routing.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/hooks/bridge.d.ts.map
generated
vendored
2
dist/hooks/bridge.d.ts.map
generated
vendored
@@ -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
191
dist/hooks/bridge.js
generated
vendored
@@ -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
2
dist/hooks/bridge.js.map
generated
vendored
File diff suppressed because one or more lines are too long
154
dist/hooks/keyword-detector/__tests__/index.test.js
generated
vendored
154
dist/hooks/keyword-detector/__tests__/index.test.js
generated
vendored
@@ -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
|
||||
2
dist/hooks/keyword-detector/__tests__/index.test.js.map
generated
vendored
2
dist/hooks/keyword-detector/__tests__/index.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
29
dist/hooks/keyword-detector/index.d.ts
generated
vendored
29
dist/hooks/keyword-detector/index.d.ts
generated
vendored
@@ -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
|
||||
2
dist/hooks/keyword-detector/index.d.ts.map
generated
vendored
2
dist/hooks/keyword-detector/index.d.ts.map
generated
vendored
@@ -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
81
dist/hooks/keyword-detector/index.js
generated
vendored
@@ -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)
|
||||
|
||||
2
dist/hooks/keyword-detector/index.js.map
generated
vendored
2
dist/hooks/keyword-detector/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/hooks/mode-registry/index.d.ts.map
generated
vendored
2
dist/hooks/mode-registry/index.d.ts.map
generated
vendored
@@ -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
65
dist/hooks/mode-registry/index.js
generated
vendored
@@ -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
|
||||
|
||||
2
dist/hooks/mode-registry/index.js.map
generated
vendored
2
dist/hooks/mode-registry/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/hooks/mode-registry/types.d.ts
generated
vendored
2
dist/hooks/mode-registry/types.d.ts
generated
vendored
@@ -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;
|
||||
|
||||
2
dist/hooks/mode-registry/types.d.ts.map
generated
vendored
2
dist/hooks/mode-registry/types.d.ts.map
generated
vendored
@@ -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"}
|
||||
77
dist/hooks/persistent-mode/__tests__/skill-state-stop.test.js
generated
vendored
77
dist/hooks/persistent-mode/__tests__/skill-state-stop.test.js
generated
vendored
@@ -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
|
||||
2
dist/hooks/persistent-mode/__tests__/skill-state-stop.test.js.map
generated
vendored
2
dist/hooks/persistent-mode/__tests__/skill-state-stop.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/hooks/persistent-mode/__tests__/workflow-gating.test.d.ts
generated
vendored
Normal file
2
dist/hooks/persistent-mode/__tests__/workflow-gating.test.d.ts
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=workflow-gating.test.d.ts.map
|
||||
1
dist/hooks/persistent-mode/__tests__/workflow-gating.test.d.ts.map
generated
vendored
Normal file
1
dist/hooks/persistent-mode/__tests__/workflow-gating.test.d.ts.map
generated
vendored
Normal 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":""}
|
||||
245
dist/hooks/persistent-mode/__tests__/workflow-gating.test.js
generated
vendored
Normal file
245
dist/hooks/persistent-mode/__tests__/workflow-gating.test.js
generated
vendored
Normal 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
|
||||
1
dist/hooks/persistent-mode/__tests__/workflow-gating.test.js.map
generated
vendored
Normal file
1
dist/hooks/persistent-mode/__tests__/workflow-gating.test.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/hooks/persistent-mode/index.d.ts.map
generated
vendored
2
dist/hooks/persistent-mode/index.d.ts.map
generated
vendored
@@ -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
151
dist/hooks/persistent-mode/index.js
generated
vendored
@@ -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
|
||||
|
||||
2
dist/hooks/persistent-mode/index.js.map
generated
vendored
2
dist/hooks/persistent-mode/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/hooks/recovery/types.d.ts
generated
vendored
2
dist/hooks/recovery/types.d.ts
generated
vendored
@@ -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;
|
||||
|
||||
538
dist/hooks/skill-state/__tests__/skill-state.test.js
generated
vendored
538
dist/hooks/skill-state/__tests__/skill-state.test.js
generated
vendored
@@ -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
|
||||
2
dist/hooks/skill-state/__tests__/skill-state.test.js.map
generated
vendored
2
dist/hooks/skill-state/__tests__/skill-state.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
205
dist/hooks/skill-state/index.d.ts
generated
vendored
205
dist/hooks/skill-state/index.d.ts
generated
vendored
@@ -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;
|
||||
|
||||
2
dist/hooks/skill-state/index.d.ts.map
generated
vendored
2
dist/hooks/skill-state/index.d.ts.map
generated
vendored
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAcH,eAAO,MAAM,uBAAuB,iBAAiB,CAAC;AACtD,eAAO,MAAM,uBAAuB,4BAA4B,CAAC;AACjE,eAAO,MAAM,yBAAyB,QAAsB,CAAC;AAE7D;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,8GAS5B,CAAC;AACX,MAAM,MAAM,sBAAsB,GAAG,OAAO,yBAAyB,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,IAAI,sBAAsB,CAG/F;AAMD,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;AAiED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAMjG;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAEzF;AAMD,uEAAuE;AACvE,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;AAED,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,6DAA6D;IAC7D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,eAAe,EAAE,MAAM,CAAC;IACxB,0EAA0E;IAC1E,gBAAgB,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,sBAAsB,EAAE,MAAM,CAAC;IAC/B,2EAA2E;IAC3E,8BAA8B,EAAE,MAAM,CAAC;IACvC,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,uBAAuB;AACvB,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,CAAC,CAAC;IACX,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC/C,aAAa,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,kCAAkC;IACjD;;;;OAIG;IACH,SAAS,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACvC;AAMD,wBAAgB,uBAAuB,IAAI,kBAAkB,CAE5D;AAkED,qEAAqE;AACrE,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,MAAM,EACjB,QAAQ,GAAE,OAAO,CAAC,eAAe,CAAM,GACtC,kBAAkB,CAiCpB;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,MAAiC,GACrC,kBAAkB,CASpB;AAED,sEAAsE;AACtE,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAMpB;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CACjD,KAAK,EAAE,kBAAkB,EACzB,KAAK,GAAE,MAAkC,EACzC,GAAG,GAAE,MAAmB,GACvB,kBAAkB,CAqBpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,kBAAkB,GACxB,eAAe,GAAG,IAAI,CAmBxB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,MAAM,GAChB,OAAO,CAIT;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,MAAM,EACjB,KAAK,GAAE,MAAkC,EACzC,GAAG,GAAE,MAAmB,GACvB,OAAO,CAOT;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,kBAAkB,CAsCpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,kBAAkB,EAC7B,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,kCAAkC,GAC3C,OAAO,CA2CT;AAMD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,gBAAgB,GAAG,IAAI,CAKzB;AAED;;;;;;;;;;GAUG;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,CA+BzB;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAIpF;AAED,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,CAyE/D"}
|
||||
553
dist/hooks/skill-state/index.js
generated
vendored
553
dist/hooks/skill-state/index.js
generated
vendored
@@ -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
|
||||
2
dist/hooks/skill-state/index.js.map
generated
vendored
2
dist/hooks/skill-state/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
6
dist/hooks/think-mode/__tests__/index.test.js
generated
vendored
6
dist/hooks/think-mode/__tests__/index.test.js
generated
vendored
@@ -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
4
dist/hud/elements/model.js
generated
vendored
@@ -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
4
dist/hud/types.d.ts
generated
vendored
@@ -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';
|
||||
|
||||
56
dist/installer/__tests__/mcp-registry.test.js
generated
vendored
56
dist/installer/__tests__/mcp-registry.test.js
generated
vendored
@@ -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 },
|
||||
|
||||
2
dist/installer/__tests__/mcp-registry.test.js.map
generated
vendored
2
dist/installer/__tests__/mcp-registry.test.js.map
generated
vendored
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
2
dist/installer/__tests__/plugin-cache-sync.test.d.ts
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=plugin-cache-sync.test.d.ts.map
|
||||
1
dist/installer/__tests__/plugin-cache-sync.test.d.ts.map
generated
vendored
Normal file
1
dist/installer/__tests__/plugin-cache-sync.test.d.ts.map
generated
vendored
Normal 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
117
dist/installer/__tests__/plugin-cache-sync.test.js
generated
vendored
Normal 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
|
||||
1
dist/installer/__tests__/plugin-cache-sync.test.js.map
generated
vendored
Normal file
1
dist/installer/__tests__/plugin-cache-sync.test.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
10
dist/installer/index.d.ts
generated
vendored
10
dist/installer/index.d.ts
generated
vendored
@@ -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
2
dist/installer/index.d.ts.map
generated
vendored
@@ -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
164
dist/installer/index.js
generated
vendored
@@ -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
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
1
dist/installer/mcp-registry.d.ts
generated
vendored
@@ -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>;
|
||||
|
||||
2
dist/installer/mcp-registry.d.ts.map
generated
vendored
2
dist/installer/mcp-registry.d.ts.map
generated
vendored
@@ -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
12
dist/installer/mcp-registry.js
generated
vendored
@@ -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
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
2
dist/lib/mode-names.d.ts
generated
vendored
@@ -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
2
dist/lib/mode-names.d.ts.map
generated
vendored
@@ -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
10
dist/lib/mode-names.js
generated
vendored
@@ -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
2
dist/lib/mode-names.js.map
generated
vendored
@@ -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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"}
|
||||
2
dist/team/__tests__/runtime-v2.gemini-preflight.test.d.ts
generated
vendored
Normal file
2
dist/team/__tests__/runtime-v2.gemini-preflight.test.d.ts
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=runtime-v2.gemini-preflight.test.d.ts.map
|
||||
1
dist/team/__tests__/runtime-v2.gemini-preflight.test.d.ts.map
generated
vendored
Normal file
1
dist/team/__tests__/runtime-v2.gemini-preflight.test.d.ts.map
generated
vendored
Normal 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":""}
|
||||
97
dist/team/__tests__/runtime-v2.gemini-preflight.test.js
generated
vendored
Normal file
97
dist/team/__tests__/runtime-v2.gemini-preflight.test.js
generated
vendored
Normal 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
|
||||
1
dist/team/__tests__/runtime-v2.gemini-preflight.test.js.map
generated
vendored
Normal file
1
dist/team/__tests__/runtime-v2.gemini-preflight.test.js.map
generated
vendored
Normal 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
2
dist/team/runtime-v2.d.ts.map
generated
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"runtime-v2.d.ts","sourceRoot":"","sources":["../../src/team/runtime-v2.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AA8BH,OAAO,KAAK,EACV,UAAU,EAEV,QAAQ,EAER,YAAY,EACZ,eAAe,EAChB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAuBvD,OAAO,KAAK,EAAqB,YAAY,EAA0C,MAAM,oBAAoB,CAAC;AAMlH,OAAO,EAKL,KAAK,sBAAsB,EAC5B,MAAM,0BAA0B,CAAC;AAMlC,wBAAgB,kBAAkB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAKhF;AAMD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,CAAC;IACjB,OAAO,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,OAAO,CAAC;QACf,MAAM,EAAE,YAAY,CAAC;QACrB,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;QAClC,aAAa,EAAE,MAAM,EAAE,CAAC;QACxB,oBAAoB,EAAE,MAAM,CAAC;KAC9B,CAAC,CAAC;IACH,KAAK,EAAE;QACL,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,QAAQ,EAAE,CAAC;KACnB,CAAC;IACF,gBAAgB,EAAE,OAAO,CAAC;IAC1B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,WAAW,EAAE;QACX,aAAa,EAAE,MAAM,CAAC;QACtB,cAAc,EAAE,MAAM,CAAC;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAmJD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7G,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B;AAuXD;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAmTnF;AAQD,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAWf;AAED;;;;;GAKG;AACH,qBAAa,gBAAgB;IAKzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAN5B,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,OAAO,CAAS;gBAGL,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAAkC;IAGhE,aAAa,IAAI,IAAI;IAIf,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAUrD,SAAS,IAAI,OAAO;CAGrB;AAMD;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,EAAE,EACzB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,EAAE,CAAC,CAwDnB;AAMD,MAAM,MAAM,sBAAsB,GAC9B,WAAW,GACX,QAAQ,GACR,cAAc,GACd,cAAc,GACd,qBAAqB,GACrB,kBAAkB,GAClB,SAAS,CAAC;AAEd,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,sBAAsB,CAAC;IAC/B,OAAO,CAAC,EAAE,sBAAsB,CAAC,SAAS,CAAC,CAAC;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,sBAAsB,EAAE,CAAC,CA8InC;AAMD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAuMhC;AAMD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Kf;AAMD,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAqB/B;AAMD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CActE"}
|
||||
{"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
21
dist/team/runtime-v2.js
generated
vendored
@@ -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
2
dist/team/runtime-v2.js.map
generated
vendored
File diff suppressed because one or more lines are too long
180
dist/tools/__tests__/state-tools.test.js
generated
vendored
180
dist/tools/__tests__/state-tools.test.js
generated
vendored
@@ -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 () => {
|
||||
|
||||
2
dist/tools/__tests__/state-tools.test.js.map
generated
vendored
2
dist/tools/__tests__/state-tools.test.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/tools/state-tools.d.ts.map
generated
vendored
2
dist/tools/state-tools.d.ts.map
generated
vendored
@@ -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
10
dist/tools/state-tools.js
generated
vendored
@@ -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
2
dist/tools/state-tools.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,7 +9,9 @@ export type ExecutionMode =
|
||||
| 'team'
|
||||
| 'ralph'
|
||||
| 'ultrawork'
|
||||
| 'ultraqa';
|
||||
| 'ultraqa'
|
||||
| 'deep-interview'
|
||||
| 'self-improve';
|
||||
|
||||
export interface ModeConfig {
|
||||
/** Display name for the mode */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
289
src/hooks/persistent-mode/__tests__/workflow-gating.test.ts
Normal file
289
src/hooks/persistent-mode/__tests__/workflow-gating.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user