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.
- package/dist/artifact-linter.js +89 -6
- package/dist/config.d.ts +8 -1
- package/dist/config.js +9 -6
- package/dist/content/examples.js +1 -0
- package/dist/content/hook-events.js +1 -5
- package/dist/content/hook-manifest.d.ts +2 -4
- package/dist/content/hook-manifest.js +4 -3
- package/dist/content/meta-skill.js +7 -9
- package/dist/content/next-command.js +2 -2
- package/dist/content/node-hooks.js +15 -16
- package/dist/content/observe.js +2 -4
- package/dist/content/opencode-plugin.js +5 -6
- package/dist/content/review-loop.js +15 -5
- package/dist/content/review-prompts.js +1 -1
- package/dist/content/skills.js +3 -2
- package/dist/content/stage-schema.d.ts +0 -1
- package/dist/content/stage-schema.js +2 -5
- package/dist/content/stages/brainstorm.js +3 -3
- package/dist/content/stages/design.js +18 -17
- package/dist/content/stages/plan.js +2 -1
- package/dist/content/stages/review.js +10 -10
- package/dist/content/stages/scope.js +13 -13
- package/dist/content/stages/spec.js +7 -5
- package/dist/content/stages/tdd.js +2 -2
- package/dist/content/start-command.d.ts +4 -3
- package/dist/content/start-command.js +21 -17
- package/dist/content/templates.d.ts +1 -1
- package/dist/content/templates.js +49 -29
- package/dist/content/view-command.js +3 -1
- package/dist/delegation.d.ts +0 -1
- package/dist/delegation.js +29 -11
- package/dist/doctor.js +148 -24
- package/dist/gate-evidence.js +19 -7
- package/dist/harness-adapters.js +1 -5
- package/dist/install.js +111 -24
- package/dist/internal/advance-stage.js +90 -11
- package/dist/knowledge-store.d.ts +4 -1
- package/dist/knowledge-store.js +24 -14
- package/dist/retro-gate.d.ts +1 -0
- package/dist/retro-gate.js +9 -9
- package/dist/run-archive.js +19 -1
- package/dist/run-persistence.js +12 -5
- package/dist/tdd-cycle.js +6 -3
- package/package.json +1 -1
package/dist/harness-adapters.js
CHANGED
|
@@ -134,11 +134,7 @@ export function harnessTier(harnessId) {
|
|
|
134
134
|
capabilities.hookSurface === "full") {
|
|
135
135
|
return "tier1";
|
|
136
136
|
}
|
|
137
|
-
if (capabilities.hookSurface
|
|
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
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
898
|
-
?
|
|
899
|
-
:
|
|
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
|
|
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
|
};
|
package/dist/knowledge-store.js
CHANGED
|
@@ -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 (!
|
|
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" ||
|
|
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
|
-
:
|
|
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
|
-
|
|
546
|
+
stageScore = 4;
|
|
540
547
|
}
|
|
541
548
|
else if (entry.origin_stage === stage) {
|
|
542
|
-
|
|
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
|
-
|
|
572
|
+
contextualScore += 2;
|
|
567
573
|
}
|
|
568
574
|
for (const token of diffTokens) {
|
|
569
575
|
if (searchSet.has(token))
|
|
570
|
-
|
|
576
|
+
contextualScore += 2;
|
|
571
577
|
}
|
|
572
578
|
for (const token of gateTokens) {
|
|
573
579
|
if (searchSet.has(token))
|
|
574
|
-
|
|
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,
|
package/dist/retro-gate.d.ts
CHANGED
package/dist/retro-gate.js
CHANGED
|
@@ -42,11 +42,10 @@ export async function evaluateRetroGate(projectRoot, state) {
|
|
|
42
42
|
hasRetroArtifact = false;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
let 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 (
|
|
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
|
|
65
|
-
if (
|
|
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
|
|
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
|
}
|
package/dist/run-archive.js
CHANGED
|
@@ -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
|
package/dist/run-persistence.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
249
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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.
|