cclaw-cli 0.51.19 → 0.51.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/artifact-linter.js +89 -6
  2. package/dist/config.d.ts +8 -1
  3. package/dist/config.js +9 -6
  4. package/dist/content/examples.js +1 -0
  5. package/dist/content/hook-events.js +1 -5
  6. package/dist/content/hook-manifest.d.ts +2 -4
  7. package/dist/content/hook-manifest.js +4 -3
  8. package/dist/content/meta-skill.js +7 -9
  9. package/dist/content/next-command.js +2 -2
  10. package/dist/content/node-hooks.js +15 -16
  11. package/dist/content/observe.js +2 -4
  12. package/dist/content/opencode-plugin.js +5 -6
  13. package/dist/content/review-loop.js +15 -5
  14. package/dist/content/review-prompts.js +1 -1
  15. package/dist/content/skills.js +3 -2
  16. package/dist/content/stage-schema.d.ts +0 -1
  17. package/dist/content/stage-schema.js +2 -5
  18. package/dist/content/stages/brainstorm.js +3 -3
  19. package/dist/content/stages/design.js +18 -17
  20. package/dist/content/stages/plan.js +2 -1
  21. package/dist/content/stages/review.js +10 -10
  22. package/dist/content/stages/scope.js +13 -13
  23. package/dist/content/stages/spec.js +7 -5
  24. package/dist/content/stages/tdd.js +2 -2
  25. package/dist/content/start-command.d.ts +4 -3
  26. package/dist/content/start-command.js +21 -17
  27. package/dist/content/templates.d.ts +1 -1
  28. package/dist/content/templates.js +49 -29
  29. package/dist/content/view-command.js +3 -1
  30. package/dist/delegation.d.ts +0 -1
  31. package/dist/delegation.js +29 -11
  32. package/dist/doctor.js +148 -24
  33. package/dist/gate-evidence.js +19 -7
  34. package/dist/harness-adapters.js +1 -5
  35. package/dist/install.js +111 -24
  36. package/dist/internal/advance-stage.js +90 -11
  37. package/dist/knowledge-store.d.ts +4 -1
  38. package/dist/knowledge-store.js +24 -14
  39. package/dist/retro-gate.d.ts +1 -0
  40. package/dist/retro-gate.js +9 -9
  41. package/dist/run-archive.js +19 -1
  42. package/dist/run-persistence.js +12 -5
  43. package/dist/tdd-cycle.js +6 -3
  44. package/package.json +1 -1
@@ -134,11 +134,7 @@ export function harnessTier(harnessId) {
134
134
  capabilities.hookSurface === "full") {
135
135
  return "tier1";
136
136
  }
137
- if (capabilities.hookSurface === "full" ||
138
- capabilities.hookSurface === "plugin" ||
139
- capabilities.hookSurface === "limited" ||
140
- capabilities.nativeSubagentDispatch === "generic" ||
141
- capabilities.nativeSubagentDispatch === "partial") {
137
+ if (capabilities.hookSurface !== "none" || capabilities.nativeSubagentDispatch !== "none") {
142
138
  return "tier2";
143
139
  }
144
140
  return "tier3";
package/dist/install.js CHANGED
@@ -34,10 +34,17 @@ const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
34
34
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
35
35
  const GIT_HOOK_MANAGED_MARKER = "cclaw-managed-git-hook";
36
36
  const GIT_HOOK_RUNTIME_REL_DIR = `${RUNTIME_ROOT}/hooks/git`;
37
+ const INIT_SENTINEL_FILE = ".init-in-progress";
37
38
  const execFileAsync = promisify(execFile);
38
39
  function runtimePath(projectRoot, ...segments) {
39
40
  return path.join(projectRoot, RUNTIME_ROOT, ...segments);
40
41
  }
42
+ async function writeInitSentinel(projectRoot, operation) {
43
+ const sentinelPath = runtimePath(projectRoot, "state", INIT_SENTINEL_FILE);
44
+ await ensureDir(path.dirname(sentinelPath));
45
+ await writeFileSafe(sentinelPath, `${JSON.stringify({ operation, startedAt: new Date().toISOString() }, null, 2)}\n`);
46
+ return sentinelPath;
47
+ }
41
48
  async function removeBestEffort(targetPath, recursive = false) {
42
49
  try {
43
50
  await fs.rm(targetPath, { recursive, force: true });
@@ -393,13 +400,37 @@ async function writeSkills(projectRoot, config) {
393
400
  // legacy per-language skill folders from v0.7.0 (.cclaw/skills/language-*)
394
401
  // are cleaned up below so the new rules/lang layout is the only truth.
395
402
  const enabledPacks = config?.languageRulePacks ?? [];
403
+ const enabledPackFileNames = new Set();
396
404
  for (const pack of enabledPacks) {
397
405
  const fileName = LANGUAGE_RULE_PACK_FILES[pack];
398
406
  const generator = LANGUAGE_RULE_PACK_GENERATORS[pack];
399
407
  if (!fileName || !generator)
400
408
  continue;
409
+ enabledPackFileNames.add(fileName);
401
410
  await writeFileSafe(runtimePath(projectRoot, ...LANGUAGE_RULE_PACK_DIR, fileName), generator());
402
411
  }
412
+ // Strict idempotence: once a pack is removed from config, its generated
413
+ // file under .cclaw/rules/lang/ must disappear on the next sync. Without
414
+ // this loop the directory accumulates a superset of every pack ever
415
+ // enabled, which silently keeps stale guidance alive.
416
+ const langDir = runtimePath(projectRoot, ...LANGUAGE_RULE_PACK_DIR);
417
+ if (await exists(langDir)) {
418
+ const knownPackFileNames = new Set(Object.values(LANGUAGE_RULE_PACK_FILES));
419
+ let entries = [];
420
+ try {
421
+ entries = await fs.readdir(langDir);
422
+ }
423
+ catch {
424
+ entries = [];
425
+ }
426
+ for (const entry of entries) {
427
+ if (!knownPackFileNames.has(entry))
428
+ continue;
429
+ if (enabledPackFileNames.has(entry))
430
+ continue;
431
+ await fs.rm(path.join(langDir, entry), { force: true });
432
+ }
433
+ }
403
434
  for (const legacyFolder of LEGACY_LANGUAGE_RULE_PACK_FOLDERS) {
404
435
  const legacyPath = runtimePath(projectRoot, "skills", legacyFolder);
405
436
  if (await exists(legacyPath)) {
@@ -629,6 +660,54 @@ async function backupHookFile(projectRoot, hookFilePath, rawContent) {
629
660
  await pruneOldHookBackups(backupsDir);
630
661
  return backupPath;
631
662
  }
663
+ function normalizeHookCommandForDedupe(command) {
664
+ return command.trim().replace(/\s+/gu, " ").replace(/\\/gu, "/");
665
+ }
666
+ function dedupeHookEntryByCommand(entry, seenCommands) {
667
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
668
+ return entry;
669
+ }
670
+ const obj = entry;
671
+ let changed = false;
672
+ if (typeof obj.command === "string") {
673
+ const normalized = normalizeHookCommandForDedupe(obj.command);
674
+ if (seenCommands.has(normalized)) {
675
+ return undefined;
676
+ }
677
+ seenCommands.add(normalized);
678
+ }
679
+ if (Array.isArray(obj.hooks)) {
680
+ const hooks = [];
681
+ for (const nested of obj.hooks) {
682
+ const deduped = dedupeHookEntryByCommand(nested, seenCommands);
683
+ if (deduped !== undefined) {
684
+ hooks.push(deduped);
685
+ }
686
+ else {
687
+ changed = true;
688
+ }
689
+ }
690
+ if (hooks.length !== obj.hooks.length) {
691
+ changed = true;
692
+ }
693
+ if (hooks.length === 0 && typeof obj.command !== "string") {
694
+ return undefined;
695
+ }
696
+ return changed ? { ...obj, hooks } : entry;
697
+ }
698
+ return entry;
699
+ }
700
+ function dedupeHookEntriesByCommand(entries) {
701
+ const seenCommands = new Set();
702
+ const deduped = [];
703
+ for (const entry of entries) {
704
+ const next = dedupeHookEntryByCommand(entry, seenCommands);
705
+ if (next !== undefined) {
706
+ deduped.push(next);
707
+ }
708
+ }
709
+ return deduped;
710
+ }
632
711
  function mergeHookDocuments(existingDoc, generatedDoc) {
633
712
  const generatedRoot = toObject(generatedDoc) ?? {};
634
713
  const generatedHooks = toObject(generatedRoot.hooks) ?? {};
@@ -640,7 +719,7 @@ function mergeHookDocuments(existingDoc, generatedDoc) {
640
719
  const existingEntries = existingHooks[eventName];
641
720
  if (Array.isArray(generatedEntries)) {
642
721
  const preservedEntries = Array.isArray(existingEntries) ? existingEntries : [];
643
- mergedHooks[eventName] = [...generatedEntries, ...preservedEntries];
722
+ mergedHooks[eventName] = dedupeHookEntriesByCommand([...generatedEntries, ...preservedEntries]);
644
723
  continue;
645
724
  }
646
725
  // Defensive: malformed generated event payload must not wipe user hooks.
@@ -920,26 +999,34 @@ async function cleanStaleFiles(projectRoot) {
920
999
  // Keep user-owned custom assets under .cclaw/agents and .cclaw/skills.
921
1000
  // Legacy managed removals happen in cleanLegacyArtifacts() with explicit paths.
922
1001
  }
923
- async function materializeRuntime(projectRoot, config, forceStateReset) {
924
- const harnesses = config.harnesses;
925
- await ensureStructure(projectRoot);
926
- await cleanLegacyArtifacts(projectRoot);
927
- await cleanStaleFiles(projectRoot);
928
- await Promise.all([
929
- writeEntryCommands(projectRoot),
930
- writeSkills(projectRoot, config),
931
- writeArtifactTemplates(projectRoot),
932
- writeRulebook(projectRoot)
933
- ]);
934
- await writeState(projectRoot, config, forceStateReset);
935
- await ensureRunSystem(projectRoot, { createIfMissing: false });
936
- await ensureKnowledgeStore(projectRoot);
937
- await writeHooks(projectRoot, config);
938
- await syncDisabledHarnessArtifacts(projectRoot, harnesses);
939
- await syncManagedGitHooks(projectRoot, config);
940
- await syncHarnessShims(projectRoot, harnesses);
941
- await writeCursorWorkflowRule(projectRoot, harnesses);
942
- await ensureGitignore(projectRoot);
1002
+ async function materializeRuntime(projectRoot, config, forceStateReset, operation = "sync") {
1003
+ const sentinelPath = await writeInitSentinel(projectRoot, operation);
1004
+ try {
1005
+ const harnesses = config.harnesses;
1006
+ await ensureStructure(projectRoot);
1007
+ await cleanLegacyArtifacts(projectRoot);
1008
+ await cleanStaleFiles(projectRoot);
1009
+ await Promise.all([
1010
+ writeEntryCommands(projectRoot),
1011
+ writeSkills(projectRoot, config),
1012
+ writeArtifactTemplates(projectRoot),
1013
+ writeRulebook(projectRoot)
1014
+ ]);
1015
+ await writeState(projectRoot, config, forceStateReset);
1016
+ await ensureRunSystem(projectRoot, { createIfMissing: false });
1017
+ await ensureKnowledgeStore(projectRoot);
1018
+ await writeHooks(projectRoot, config);
1019
+ await syncDisabledHarnessArtifacts(projectRoot, harnesses);
1020
+ await syncManagedGitHooks(projectRoot, config);
1021
+ await syncHarnessShims(projectRoot, harnesses);
1022
+ await writeCursorWorkflowRule(projectRoot, harnesses);
1023
+ await ensureGitignore(projectRoot);
1024
+ await fs.unlink(sentinelPath).catch(() => undefined);
1025
+ }
1026
+ catch (error) {
1027
+ // Leave the sentinel in place so doctor can surface the interrupted run.
1028
+ throw error;
1029
+ }
943
1030
  }
944
1031
  export async function initCclaw(options) {
945
1032
  const baseConfig = createDefaultConfig(options.harnesses, options.track);
@@ -954,7 +1041,7 @@ export async function initCclaw(options) {
954
1041
  // and only appear in the on-disk file when the user sets them explicitly
955
1042
  // or a non-default value was detected (e.g. languageRulePacks).
956
1043
  await writeConfig(options.projectRoot, config, { mode: "minimal" });
957
- await materializeRuntime(options.projectRoot, config, true);
1044
+ await materializeRuntime(options.projectRoot, config, true, "init");
958
1045
  }
959
1046
  export async function syncCclaw(projectRoot) {
960
1047
  const configExists = await exists(configPath(projectRoot));
@@ -972,7 +1059,7 @@ export async function syncCclaw(projectRoot) {
972
1059
  await writeConfig(projectRoot, defaultConfig);
973
1060
  config = defaultConfig;
974
1061
  }
975
- await materializeRuntime(projectRoot, config, false);
1062
+ await materializeRuntime(projectRoot, config, false, "sync");
976
1063
  }
977
1064
  /**
978
1065
  * Refresh generated files in `.cclaw/` without touching user-authored
@@ -996,7 +1083,7 @@ export async function upgradeCclaw(projectRoot) {
996
1083
  mode: "minimal",
997
1084
  advancedKeysPresent
998
1085
  });
999
- await materializeRuntime(projectRoot, upgraded, false);
1086
+ await materializeRuntime(projectRoot, upgraded, false, "upgrade");
1000
1087
  }
1001
1088
  function stripManagedHookCommands(value) {
1002
1089
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -230,7 +230,7 @@ function validateGateEvidenceShape(stage, gateId, evidence) {
230
230
  function reviewLoopArtifactFixHint(stage, gateId) {
231
231
  if (AUTO_REVIEW_LOOP_GATE_BY_STAGE[stage] !== gateId)
232
232
  return "";
233
- return " Add a `## Spec Review Loop` table to the artifact with rows like `| 1 | 0.80 | 0 |` plus `- Stop reason: quality_threshold_met`, `- Target score: 0.80`, and `- Max iterations: 3`; then omit this gate from manual evidence so stage-complete can auto-hydrate it.";
233
+ return ` Add a \`## ${stage === "scope" ? "Scope Outside Voice Loop" : "Design Outside Voice Loop"}\` table to the artifact with rows like \`| 1 | 0.80 | 0 |\` plus \`- Stop reason: quality_threshold_met\`, \`- Target score: 0.80\`, and \`- Max iterations: 3\`; then omit this gate from manual evidence so stage-complete can auto-hydrate it.`;
234
234
  }
235
235
  function parseStringList(raw) {
236
236
  if (!Array.isArray(raw))
@@ -401,9 +401,7 @@ async function hydrateReviewLoopEvidenceFromArtifact(projectRoot, stage, track,
401
401
  return;
402
402
  const existing = evidenceByGate[gateId];
403
403
  if (typeof existing === "string" && existing.trim().length > 0) {
404
- const existingIssue = validateGateEvidenceShape(stage, gateId, existing);
405
- if (!existingIssue)
406
- return;
404
+ return;
407
405
  }
408
406
  const resolved = await resolveArtifactPath(stage, {
409
407
  projectRoot,
@@ -617,11 +615,16 @@ function parseStartFlowArgs(tokens) {
617
615
  }
618
616
  return { track, className, prompt, reason, stack, forceReset, reclassify, quiet };
619
617
  }
620
- async function buildValidationReport(projectRoot, flowState) {
618
+ async function buildValidationReport(projectRoot, flowState, options = {}) {
621
619
  const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage);
622
620
  const gates = await verifyCurrentStageGateEvidence(projectRoot, flowState);
623
621
  const completedStages = verifyCompletedStagesGateClosure(flowState);
624
- const ok = delegation.satisfied && gates.ok && gates.complete && completedStages.ok;
622
+ const blockedReviewRouteComplete = options.allowBlockedReviewRoute === true
623
+ && flowState.currentStage === "review"
624
+ && typeof flowState.guardEvidence.review_verdict_blocked === "string"
625
+ && flowState.guardEvidence.review_verdict_blocked.trim().length > 0
626
+ && !flowState.stageGateCatalog.review.passed.includes("review_criticals_resolved");
627
+ const ok = delegation.satisfied && gates.ok && (gates.complete || blockedReviewRouteComplete) && completedStages.ok;
625
628
  return {
626
629
  ok,
627
630
  stage: flowState.currentStage,
@@ -753,7 +756,11 @@ async function runAdvanceStage(projectRoot, args, io) {
753
756
  : requiredGateIds;
754
757
  const selectedGateIdSet = new Set(selectedGateIds);
755
758
  const selectedTransitionGuards = selectedGateIds.filter((gateId) => transitionGuardIds.has(gateId));
756
- const missingRequired = requiredGateIds.filter((gateId) => !selectedGateIdSet.has(gateId));
759
+ const blockedReviewRoute = args.stage === "review" && selectedGateIdSet.has("review_verdict_blocked");
760
+ const requiredForSelectedRoute = blockedReviewRoute
761
+ ? requiredGateIds.filter((gateId) => gateId !== "review_criticals_resolved")
762
+ : requiredGateIds;
763
+ const missingRequired = requiredForSelectedRoute.filter((gateId) => !selectedGateIdSet.has(gateId));
757
764
  if (missingRequired.length > 0) {
758
765
  io.stderr.write(`cclaw internal advance-stage: required gates not selected as passed: ${missingRequired.join(", ")}.\n`);
759
766
  return 1;
@@ -844,7 +851,9 @@ async function runAdvanceStage(projectRoot, args, io) {
844
851
  [args.stage]: nextStageCatalog
845
852
  }
846
853
  };
847
- const validation = await buildValidationReport(projectRoot, candidateState);
854
+ const validation = await buildValidationReport(projectRoot, candidateState, {
855
+ allowBlockedReviewRoute: blockedReviewRoute
856
+ });
848
857
  if (!validation.ok) {
849
858
  if (args.json) {
850
859
  io.stdout.write(`${JSON.stringify({
@@ -894,9 +903,11 @@ async function runAdvanceStage(projectRoot, args, io) {
894
903
  }
895
904
  const satisfiedGuards = new Set([...nextPassed, ...selectedTransitionGuards]);
896
905
  const successor = resolveSuccessorTransition(args.stage, flowState.track, transitionTargets, satisfiedGuards, new Set(selectedTransitionGuards));
897
- const completedStages = flowState.completedStages.includes(args.stage)
898
- ? [...flowState.completedStages]
899
- : [...flowState.completedStages, args.stage];
906
+ const completedStages = blockedReviewRoute
907
+ ? flowState.completedStages.filter((stage) => stage !== args.stage)
908
+ : flowState.completedStages.includes(args.stage)
909
+ ? [...flowState.completedStages]
910
+ : [...flowState.completedStages, args.stage];
900
911
  const finalState = {
901
912
  ...candidateState,
902
913
  completedStages,
@@ -969,6 +980,55 @@ function firstIncompleteStageForTrack(track, completedStages) {
969
980
  const stages = TRACK_STAGES[track];
970
981
  return stages.find((stage) => !completed.has(stage)) ?? stages[stages.length - 1] ?? "brainstorm";
971
982
  }
983
+ function carriedCompletedStageCatalog(current, fresh, stage) {
984
+ const previousCatalog = current.stageGateCatalog[stage];
985
+ const freshCatalog = fresh.stageGateCatalog[stage];
986
+ const allowed = new Set([...freshCatalog.required, ...freshCatalog.recommended]);
987
+ const previousPassed = new Set(previousCatalog.passed.filter((gateId) => allowed.has(gateId)));
988
+ const previousBlocked = new Set(previousCatalog.blocked.filter((gateId) => allowed.has(gateId)));
989
+ const orderedAllowed = [...freshCatalog.required, ...freshCatalog.recommended];
990
+ const evidence = {};
991
+ const passed = orderedAllowed.filter((gateId) => {
992
+ if (!previousPassed.has(gateId))
993
+ return false;
994
+ const note = current.guardEvidence[gateId];
995
+ if (typeof note !== "string" || note.trim().length === 0)
996
+ return false;
997
+ evidence[gateId] = note.trim();
998
+ return true;
999
+ });
1000
+ const passedSet = new Set(passed);
1001
+ return {
1002
+ catalog: {
1003
+ required: [...freshCatalog.required],
1004
+ recommended: [...freshCatalog.recommended],
1005
+ conditional: [],
1006
+ triggered: [],
1007
+ passed,
1008
+ blocked: orderedAllowed.filter((gateId) => previousBlocked.has(gateId) && !passedSet.has(gateId))
1009
+ },
1010
+ evidence
1011
+ };
1012
+ }
1013
+ function completedStageClosureEvidenceIssues(flowState) {
1014
+ const issues = [];
1015
+ for (const stage of flowState.completedStages) {
1016
+ const schema = stageSchema(stage, flowState.track);
1017
+ const catalog = flowState.stageGateCatalog[stage];
1018
+ const required = schema.requiredGates
1019
+ .filter((gate) => gate.tier === "required")
1020
+ .map((gate) => gate.id);
1021
+ for (const gateId of required) {
1022
+ if (!catalog.passed.includes(gateId))
1023
+ continue;
1024
+ const note = flowState.guardEvidence[gateId];
1025
+ if (typeof note !== "string" || note.trim().length === 0) {
1026
+ issues.push(`completed stage "${stage}" passed gate "${gateId}" is missing guardEvidence.`);
1027
+ }
1028
+ }
1029
+ }
1030
+ return issues;
1031
+ }
972
1032
  async function ensureProactiveDelegationTrace(projectRoot, stage) {
973
1033
  const proactiveRules = stageAutoSubagentDispatch(stage).filter((rule) => rule.mode === "proactive");
974
1034
  if (proactiveRules.length === 0)
@@ -1126,13 +1186,32 @@ async function runStartFlow(projectRoot, args, io) {
1126
1186
  if (args.reclassify) {
1127
1187
  const completedInNewTrack = current.completedStages.filter((stage) => TRACK_STAGES[args.track].includes(stage));
1128
1188
  const fresh = createInitialFlowState({ activeRunId: current.activeRunId, track: args.track });
1189
+ const stageGateCatalog = { ...fresh.stageGateCatalog };
1190
+ const guardEvidence = {};
1191
+ for (const stage of completedInNewTrack) {
1192
+ const carried = carriedCompletedStageCatalog(current, fresh, stage);
1193
+ stageGateCatalog[stage] = carried.catalog;
1194
+ Object.assign(guardEvidence, carried.evidence);
1195
+ }
1129
1196
  nextState = {
1130
1197
  ...fresh,
1131
1198
  completedStages: completedInNewTrack,
1132
1199
  currentStage: firstIncompleteStageForTrack(args.track, completedInNewTrack),
1200
+ guardEvidence,
1201
+ stageGateCatalog,
1133
1202
  rewinds: current.rewinds,
1134
1203
  staleStages: current.staleStages
1135
1204
  };
1205
+ const validation = await buildValidationReport(projectRoot, nextState);
1206
+ const evidenceIssues = completedStageClosureEvidenceIssues(nextState);
1207
+ if (!validation.completedStages.ok || evidenceIssues.length > 0) {
1208
+ io.stderr.write("cclaw internal start-flow: reclassification would leave completed stages without valid gate closure.\n");
1209
+ const issues = [...validation.completedStages.issues, ...evidenceIssues];
1210
+ if (issues.length > 0) {
1211
+ io.stderr.write(`- completed-stage closure issues: ${issues.join(" | ")}\n`);
1212
+ }
1213
+ return 1;
1214
+ }
1136
1215
  }
1137
1216
  else {
1138
1217
  nextState = createInitialFlowState({ track: args.track });
@@ -174,7 +174,10 @@ export declare function effectiveCompoundThreshold(baseThreshold: number, archiv
174
174
  * as ready.
175
175
  */
176
176
  export declare function computeCompoundReadiness(entries: KnowledgeEntry[], options?: ComputeCompoundReadinessOptions): CompoundReadiness;
177
- export declare function validateKnowledgeEntry(entry: unknown): {
177
+ export interface ValidateKnowledgeEntryOptions {
178
+ allowLegacyOriginFeature?: boolean;
179
+ }
180
+ export declare function validateKnowledgeEntry(entry: unknown, options?: ValidateKnowledgeEntryOptions): {
178
181
  ok: boolean;
179
182
  errors: string[];
180
183
  };
@@ -171,11 +171,13 @@ const KNOWLEDGE_REQUIRED_KEYS = [
171
171
  "project"
172
172
  ];
173
173
  const KNOWLEDGE_ALLOWED_KEYS = new Set(KNOWLEDGE_REQUIRED_KEYS);
174
- KNOWLEDGE_ALLOWED_KEYS.add("origin_feature");
175
174
  KNOWLEDGE_ALLOWED_KEYS.add("source");
176
175
  KNOWLEDGE_ALLOWED_KEYS.add("severity");
177
176
  KNOWLEDGE_ALLOWED_KEYS.add("supersedes");
178
177
  KNOWLEDGE_ALLOWED_KEYS.add("superseded_by");
178
+ function keyAllowedInKnowledgeEntry(key, options) {
179
+ return KNOWLEDGE_ALLOWED_KEYS.has(key) || (options.allowLegacyOriginFeature === true && key === "origin_feature");
180
+ }
179
181
  function knowledgePath(projectRoot) {
180
182
  return path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
181
183
  }
@@ -236,7 +238,7 @@ function parseKnowledgeSnapshot(raw) {
236
238
  continue;
237
239
  try {
238
240
  const parsed = JSON.parse(trimmed);
239
- const validated = validateKnowledgeEntry(parsed);
241
+ const validated = validateKnowledgeEntry(parsed, { allowLegacyOriginFeature: true });
240
242
  if (!validated.ok) {
241
243
  malformedLines += 1;
242
244
  continue;
@@ -294,20 +296,22 @@ function isNullableString(value) {
294
296
  function isNullableStage(value) {
295
297
  return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
296
298
  }
297
- export function validateKnowledgeEntry(entry) {
299
+ export function validateKnowledgeEntry(entry, options = {}) {
298
300
  const errors = [];
299
301
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
300
302
  return { ok: false, errors: ["Knowledge entry must be a JSON object."] };
301
303
  }
302
304
  const obj = entry;
303
305
  for (const key of Object.keys(obj)) {
304
- if (!KNOWLEDGE_ALLOWED_KEYS.has(key)) {
306
+ if (!keyAllowedInKnowledgeEntry(key, options)) {
305
307
  errors.push(`Unknown key "${key}" in knowledge entry.`);
306
308
  }
307
309
  }
308
310
  for (const key of KNOWLEDGE_REQUIRED_KEYS) {
309
311
  if (!Object.prototype.hasOwnProperty.call(obj, key)) {
310
- if (key !== "origin_run" || !Object.prototype.hasOwnProperty.call(obj, "origin_feature")) {
312
+ if (key !== "origin_run" ||
313
+ options.allowLegacyOriginFeature !== true ||
314
+ !Object.prototype.hasOwnProperty.call(obj, "origin_feature")) {
311
315
  errors.push(`Missing required key "${key}".`);
312
316
  }
313
317
  }
@@ -339,7 +343,9 @@ export function validateKnowledgeEntry(entry) {
339
343
  }
340
344
  const originRun = Object.prototype.hasOwnProperty.call(obj, "origin_run")
341
345
  ? obj.origin_run
342
- : obj.origin_feature;
346
+ : options.allowLegacyOriginFeature === true
347
+ ? obj.origin_feature
348
+ : undefined;
343
349
  if (!isNullableString(originRun)) {
344
350
  errors.push("origin_run must be string or null.");
345
351
  }
@@ -534,16 +540,15 @@ export async function selectRelevantLearnings(projectRoot, options = {}) {
534
540
  : 8;
535
541
  const ranked = entries.map((entry, index) => {
536
542
  let score = 0;
543
+ let stageScore = 0;
537
544
  if (stage) {
538
545
  if (entry.stage === stage) {
539
- score += 4;
546
+ stageScore = 4;
540
547
  }
541
548
  else if (entry.origin_stage === stage) {
542
- score += 3;
543
- }
544
- else if (entry.stage === null) {
545
- score += 1;
549
+ stageScore = 3;
546
550
  }
551
+ score += stageScore;
547
552
  }
548
553
  if (entry.confidence === "high")
549
554
  score += 2;
@@ -561,17 +566,22 @@ export async function selectRelevantLearnings(projectRoot, options = {}) {
561
566
  ...tokenizeText(entry.project)
562
567
  ];
563
568
  const searchSet = new Set(searchable);
569
+ let contextualScore = 0;
564
570
  for (const token of branchTokens) {
565
571
  if (searchSet.has(token))
566
- score += 2;
572
+ contextualScore += 2;
567
573
  }
568
574
  for (const token of diffTokens) {
569
575
  if (searchSet.has(token))
570
- score += 2;
576
+ contextualScore += 2;
571
577
  }
572
578
  for (const token of gateTokens) {
573
579
  if (searchSet.has(token))
574
- score += 2;
580
+ contextualScore += 2;
581
+ }
582
+ score += contextualScore;
583
+ if (stage && entry.stage === null && stageScore === 0 && contextualScore < 4) {
584
+ score = 0;
575
585
  }
576
586
  return {
577
587
  index,
@@ -4,5 +4,6 @@ export interface RetroGateStatus {
4
4
  completed: boolean;
5
5
  compoundEntries: number;
6
6
  hasRetroArtifact: boolean;
7
+ skipped: boolean;
7
8
  }
8
9
  export declare function evaluateRetroGate(projectRoot: string, state: FlowState): Promise<RetroGateStatus>;
@@ -42,11 +42,10 @@ export async function evaluateRetroGate(projectRoot, state) {
42
42
  hasRetroArtifact = false;
43
43
  }
44
44
  }
45
- let compoundEntries = state.retro.compoundEntries;
45
+ let compoundEntries = 0;
46
46
  let windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
47
47
  let windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
48
- if (compoundEntries <= 0 &&
49
- hasRetroArtifact &&
48
+ if (hasRetroArtifact &&
50
49
  windowStartMs === null &&
51
50
  windowEndMs === null) {
52
51
  try {
@@ -61,8 +60,8 @@ export async function evaluateRetroGate(projectRoot, state) {
61
60
  // fallback scan remains disabled when mtime cannot be read
62
61
  }
63
62
  }
64
- const shouldFallbackScan = compoundEntries <= 0 && (windowStartMs !== null || windowEndMs !== null);
65
- if (shouldFallbackScan) {
63
+ const shouldScanCompoundEvidence = windowStartMs !== null || windowEndMs !== null;
64
+ if (shouldScanCompoundEvidence) {
66
65
  const countIfEligible = (parsed) => {
67
66
  if (parsed.type !== "compound") {
68
67
  return 0;
@@ -80,7 +79,6 @@ export async function evaluateRetroGate(projectRoot, state) {
80
79
  try {
81
80
  const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
82
81
  const { entries } = await readKnowledgeSafely(projectRoot);
83
- compoundEntries = 0;
84
82
  for (const parsed of entries) {
85
83
  compoundEntries += countIfEligible(parsed);
86
84
  }
@@ -111,12 +109,13 @@ export async function evaluateRetroGate(projectRoot, state) {
111
109
  // promoted during the retro window OR compound was explicitly skipped
112
110
  // after reviewing the draft), or
113
111
  // - the operator explicitly skipped the retro step itself
114
- // (`retroSkipped === true` with a reason). `retroSkipped` is an
112
+ // (`retroSkipped === true` with a non-empty reason). `retroSkipped` is an
115
113
  // operator-level override of the artifact requirement, so it must
116
114
  // bypass `hasRetroArtifact` — otherwise a run that legitimately had
117
115
  // nothing worth retro-ing dead-locks at closeout waiting for a
118
116
  // file that will never exist.
119
- const retroSkipped = state.closeout.retroSkipped === true;
117
+ const retroSkipReason = state.closeout.retroSkipReason?.trim() ?? "";
118
+ const retroSkipped = state.closeout.retroSkipped === true && retroSkipReason.length > 0;
120
119
  const compoundSkipped = state.closeout.compoundSkipped === true;
121
120
  const artifactPathComplete = hasRetroArtifact && (compoundEntries > 0 || compoundSkipped);
122
121
  const completed = required ? retroSkipped || artifactPathComplete : true;
@@ -124,6 +123,7 @@ export async function evaluateRetroGate(projectRoot, state) {
124
123
  required,
125
124
  completed,
126
125
  compoundEntries,
127
- hasRetroArtifact
126
+ hasRetroArtifact,
127
+ skipped: retroSkipped
128
128
  };
129
129
  }
@@ -83,6 +83,24 @@ async function resetCarryoverStateFiles(projectRoot, activeRunId) {
83
83
  await writeFileSafe(path.join(stateDir, TDD_CYCLE_LOG_FILE), "", { mode: 0o600 });
84
84
  await writeFileSafe(path.join(stateDir, RECONCILIATION_NOTICES_FILE), `${JSON.stringify({ schemaVersion: 1, notices: [] }, null, 2)}\n`, { mode: 0o600 });
85
85
  }
86
+ async function restoreStateSnapshot(projectRoot, archiveStatePath) {
87
+ if (!(await exists(archiveStatePath)))
88
+ return;
89
+ const stateDir = stateDirPath(projectRoot);
90
+ await ensureDir(stateDir);
91
+ const entries = await fs.readdir(archiveStatePath, { withFileTypes: true });
92
+ for (const entry of entries) {
93
+ const from = path.join(archiveStatePath, entry.name);
94
+ const to = path.join(stateDir, entry.name);
95
+ if (entry.isDirectory()) {
96
+ await fs.rm(to, { recursive: true, force: true });
97
+ await fs.cp(from, to, { recursive: true });
98
+ }
99
+ else if (entry.isFile()) {
100
+ await fs.copyFile(from, to);
101
+ }
102
+ }
103
+ }
86
104
  function toArchiveDate(date = new Date()) {
87
105
  const yyyy = date.getFullYear().toString();
88
106
  const mm = (date.getMonth() + 1).toString().padStart(2, "0");
@@ -296,8 +314,8 @@ export async function archiveRun(projectRoot, runName, options = {}) {
296
314
  }
297
315
  if (stateReset) {
298
316
  try {
317
+ await restoreStateSnapshot(projectRoot, path.join(archivePath, "state"));
299
318
  await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true, skipLock: true });
300
- await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
301
319
  }
302
320
  catch {
303
321
  // If rollback of state fails, keep sentinel + archive remnants for
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
- import { canTransition, createInitialCloseoutState, createInitialFlowState, FLOW_STATE_SCHEMA_VERSION, isFlowTrack, skippedStagesForTrack, SHIP_SUBSTATES } from "./flow-state.js";
4
+ import { nextStage, createInitialCloseoutState, createInitialFlowState, FLOW_STATE_SCHEMA_VERSION, isFlowTrack, skippedStagesForTrack, SHIP_SUBSTATES } from "./flow-state.js";
5
5
  import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
6
6
  import { FLOW_STAGES } from "./types.js";
7
7
  export class InvalidStageTransitionError extends Error {
@@ -38,8 +38,11 @@ function validateFlowTransition(prev, next) {
38
38
  if (prev.currentStage === next.currentStage) {
39
39
  return;
40
40
  }
41
- if (!canTransition(prev.currentStage, next.currentStage)) {
42
- throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `no transition rule allows "${prev.currentStage}" -> "${next.currentStage}". Use /cc-next to advance stages or archive the run to reset.`);
41
+ const naturalForward = nextStage(prev.currentStage, prev.track);
42
+ const isNaturalForward = naturalForward === next.currentStage;
43
+ const isReviewRewind = prev.currentStage === "review" && next.currentStage === "tdd";
44
+ if (!isNaturalForward && !isReviewRewind) {
45
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `no transition rule allows "${prev.currentStage}" -> "${next.currentStage}" for track "${prev.track}". Use /cc-next to advance stages or archive the run to reset.`);
43
46
  }
44
47
  }
45
48
  function flowStatePath(projectRoot) {
@@ -245,8 +248,12 @@ function sanitizeCloseoutState(value) {
245
248
  let shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
246
249
  const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
247
250
  const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
248
- const retroSkipped = typeof typed.retroSkipped === "boolean" ? typed.retroSkipped : undefined;
249
- const retroSkipReason = typeof typed.retroSkipReason === "string" ? typed.retroSkipReason : undefined;
251
+ const retroSkipReason = typeof typed.retroSkipReason === "string"
252
+ ? typed.retroSkipReason.trim() || undefined
253
+ : undefined;
254
+ const retroSkipped = typed.retroSkipped === true && retroSkipReason !== undefined
255
+ ? true
256
+ : undefined;
250
257
  const compoundCompletedAt = typeof typed.compoundCompletedAt === "string" ? typed.compoundCompletedAt : undefined;
251
258
  const compoundSkipped = typeof typed.compoundSkipped === "boolean" ? typed.compoundSkipped : undefined;
252
259
  const promotedRaw = typed.compoundPromoted;
package/dist/tdd-cycle.js CHANGED
@@ -146,7 +146,6 @@ export function validateTddCycleOrder(entries, options = {}) {
146
146
  issues.push(`slice ${slice}: refactor logged before green`);
147
147
  continue;
148
148
  }
149
- state = "need_red";
150
149
  }
151
150
  if (state === "red_open") {
152
151
  openRedSlices.push(slice);
@@ -191,8 +190,12 @@ export function pathMatchesTarget(candidate, target) {
191
190
  if (normalizedCandidate.length === 0 || normalizedTarget.length === 0) {
192
191
  return false;
193
192
  }
194
- return (normalizedCandidate === normalizedTarget ||
195
- normalizedCandidate.endsWith(`/${normalizedTarget}`));
193
+ if (normalizedCandidate === normalizedTarget) {
194
+ return true;
195
+ }
196
+ // Only allow suffix matching for multi-segment targets. A bare basename
197
+ // like `app.ts` is too broad and can match unrelated files in any folder.
198
+ return normalizedTarget.includes("/") && normalizedCandidate.endsWith(`/${normalizedTarget}`);
196
199
  }
197
200
  /**
198
201
  * Derive a lightweight Ralph Loop summary from parsed tdd-cycle-log entries.