mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-20 21:00:50 +08:00
Merge pull request #2701 from Yeachan-Heo/refactor/stateful-skills-state-init
refactor(skill-state): harden stateful-skills keyword detection and state init
This commit is contained in:
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":"AAAA;;;;;;;;;GASG;AAcH,OAAO,KAAK,EACV,aAAa,EACb,UAAU,EACV,UAAU,EACV,cAAc,EACf,MAAM,YAAY,CAAC;AASpB,YAAY,EACV,aAAa,EACb,UAAU,EACV,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB;;;;;GAKG;AACH,QAAA,MAAM,YAAY,EAAE,MAAM,CAAC,aAAa,EAAE,UAAU,CAwCnD,CAAC;AAGF,OAAO,EAAE,YAAY,EAAE,CAAC;AAOxB;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAGhD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,aAAa,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,CAMR;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,aAAa,GAClB,MAAM,GAAG,IAAI,CAIf;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CAG1E;AAyHD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAET;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,aAAa,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAGT;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,aAAa,EAAE,CAUjB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAOxE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,GAAG,cAAc,CAmB7E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,UAAU,EAAE,CAMd;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAyHT;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CA+BvD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,GACV,OAAO,CAeT;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,GACV,MAAM,EAAE,CAGV;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAA4B,GACrC,MAAM,EAAE,CAoCV;AAMD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,OAAO,CAsBT;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAgB1E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,GACV,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAehC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAgB3E"}
|
||||
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
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":"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"}
|
||||
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
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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"}
|
||||
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":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA0BxB,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAQ5C,QAAA,MAAM,gBAAgB,EAAE,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAO3C,CAAC;AA2KF,eAAO,MAAM,aAAa,EAAE,cAAc,CAAC;IACzC,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC,CAAC;IACzC,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACxC,CAmHA,CAAC;AAMF,eAAO,MAAM,cAAc,EAAE,cAAc,CAAC;IAC1C,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC,CAAC;IACzC,MAAM,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;IACpC,SAAS,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACtC,cAAc,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC3C,aAAa,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC1C,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,SAAS,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACtC,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACvC,YAAY,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACzC,KAAK,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAClC,KAAK,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IAC7D,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACxC,CAyHA,CAAC;AAMF,eAAO,MAAM,cAAc,EAAE,cAAc,CAAC;IAC1C,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC,CAAC;IACzC,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACxC,CA4OA,CAAC;AAMF,eAAO,MAAM,mBAAmB,EAAE,cAAc,CAAC;IAC/C,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACxC,CA6IA,CAAC;AAMF,eAAO,MAAM,kBAAkB,EAAE,cAAc,CAAC;IAC9C,IAAI,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC;IACxD,gBAAgB,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC7C,UAAU,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACxC,CA0KA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU;UAx0Bf,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC;sBACtB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;gBAChC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;;UA2HhC,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC;YAChC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC;eACxB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;oBACrB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;mBAC3B,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;sBACvB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;eACjC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;gBACzB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;kBACxB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;WACjC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;WAC1B,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC;sBAC1C,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;gBAChC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;;sBAuXpB,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;gBAChC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;;UAqJhC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,gBAAgB,CAAC,CAAC;sBACrC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;gBAChC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;KAsLvC,CAAC"}
|
||||
{"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
|
||||
@@ -1648,54 +1683,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