cclaw-cli 0.55.2 → 1.0.0

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 (72) hide show
  1. package/README.md +3 -3
  2. package/dist/artifact-linter/brainstorm.js +45 -1
  3. package/dist/artifact-linter/design.js +32 -1
  4. package/dist/artifact-linter/plan.js +22 -1
  5. package/dist/artifact-linter/review.js +35 -1
  6. package/dist/artifact-linter/scope.js +19 -9
  7. package/dist/artifact-linter/shared.d.ts +11 -10
  8. package/dist/artifact-linter/shared.js +70 -41
  9. package/dist/artifact-linter/ship.js +36 -0
  10. package/dist/artifact-linter/spec.js +23 -1
  11. package/dist/artifact-linter/tdd.js +74 -0
  12. package/dist/artifact-linter.d.ts +1 -1
  13. package/dist/constants.d.ts +1 -1
  14. package/dist/constants.js +1 -0
  15. package/dist/content/closeout-guidance.d.ts +1 -1
  16. package/dist/content/closeout-guidance.js +10 -11
  17. package/dist/content/core-agents.d.ts +35 -36
  18. package/dist/content/core-agents.js +189 -99
  19. package/dist/content/diff-command.js +1 -1
  20. package/dist/content/examples.d.ts +0 -3
  21. package/dist/content/examples.js +197 -752
  22. package/dist/content/idea.d.ts +60 -0
  23. package/dist/content/idea.js +404 -0
  24. package/dist/content/learnings.d.ts +2 -4
  25. package/dist/content/learnings.js +10 -26
  26. package/dist/content/node-hooks.js +131 -97
  27. package/dist/content/opencode-plugin.js +12 -26
  28. package/dist/content/reference-patterns.js +2 -2
  29. package/dist/content/runtime-shared-snippets.d.ts +8 -0
  30. package/dist/content/runtime-shared-snippets.js +80 -0
  31. package/dist/content/session-hooks.js +1 -1
  32. package/dist/content/skills.d.ts +1 -0
  33. package/dist/content/skills.js +50 -0
  34. package/dist/content/stage-schema.js +107 -63
  35. package/dist/content/stages/review.js +8 -8
  36. package/dist/content/stages/schema-types.d.ts +2 -2
  37. package/dist/content/stages/scope.js +1 -1
  38. package/dist/content/stages/ship.js +1 -1
  39. package/dist/content/status-command.js +3 -3
  40. package/dist/content/subagent-context-skills.js +156 -1
  41. package/dist/content/subagents.d.ts +0 -5
  42. package/dist/content/subagents.js +12 -82
  43. package/dist/content/templates.js +87 -6
  44. package/dist/content/utility-skills.js +26 -97
  45. package/dist/flow-state.d.ts +5 -6
  46. package/dist/flow-state.js +4 -6
  47. package/dist/gate-evidence.d.ts +0 -31
  48. package/dist/gate-evidence.js +3 -181
  49. package/dist/harness-adapters.js +1 -1
  50. package/dist/install.js +38 -4
  51. package/dist/internal/advance-stage/advance.js +0 -1
  52. package/dist/internal/advance-stage/review-loop.js +1 -10
  53. package/dist/knowledge-store.d.ts +2 -20
  54. package/dist/knowledge-store.js +43 -57
  55. package/dist/policy.js +3 -3
  56. package/dist/retro-gate.js +8 -90
  57. package/dist/run-archive.js +1 -4
  58. package/dist/run-persistence.js +14 -109
  59. package/dist/runtime/run-hook.entry.d.ts +3 -0
  60. package/dist/runtime/run-hook.entry.js +5 -0
  61. package/dist/runtime/run-hook.mjs +9477 -0
  62. package/package.json +4 -2
  63. package/dist/content/hook-inline-snippets.d.ts +0 -96
  64. package/dist/content/hook-inline-snippets.js +0 -515
  65. package/dist/content/idea-command.d.ts +0 -8
  66. package/dist/content/idea-command.js +0 -322
  67. package/dist/content/idea-frames.d.ts +0 -31
  68. package/dist/content/idea-frames.js +0 -140
  69. package/dist/content/idea-ranking.d.ts +0 -25
  70. package/dist/content/idea-ranking.js +0 -65
  71. package/dist/trace-matrix.d.ts +0 -27
  72. package/dist/trace-matrix.js +0 -226
@@ -32,9 +32,8 @@ export function effectiveCompoundThreshold(baseThreshold, archivedRunsCount) {
32
32
  *
33
33
  * Clustering key: `(type, normalizeText(trigger), normalizeText(action))`
34
34
  * which mirrors the compound readiness clustering in runtime state.
35
- * Entries with `maturity === "lifted-to-enforcement"` or `superseded_by`
36
- * are excluded they were already promoted/replaced and should not re-appear
37
- * as ready.
35
+ * The readiness surface intentionally stays simple: no maturity/supersede
36
+ * suppression logic in this pass.
38
37
  */
39
38
  export function computeCompoundReadiness(entries, options = {}) {
40
39
  const thresholdRaw = options.threshold ?? DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
@@ -54,8 +53,6 @@ export function computeCompoundReadiness(entries, options = {}) {
54
53
  const { threshold, relaxationApplied } = effectiveCompoundThreshold(baseThreshold, archivedRunsCount);
55
54
  const buckets = new Map();
56
55
  for (const entry of entries) {
57
- if (entry.maturity === "lifted-to-enforcement" || entry.superseded_by !== undefined)
58
- continue;
59
56
  const key = [
60
57
  entry.type,
61
58
  normalizeText(entry.trigger),
@@ -71,15 +68,13 @@ export function computeCompoundReadiness(entries, options = {}) {
71
68
  entryCount: 1,
72
69
  severity: entry.severity,
73
70
  lastSeenTs: entry.last_seen_ts,
74
- types: new Set([entry.type]),
75
- maturity: new Set([entry.maturity])
71
+ types: new Set([entry.type])
76
72
  });
77
73
  continue;
78
74
  }
79
75
  bucket.recurrence += frequency;
80
76
  bucket.entryCount += 1;
81
77
  bucket.types.add(entry.type);
82
- bucket.maturity.add(entry.maturity);
83
78
  if (entry.severity === "critical") {
84
79
  bucket.severity = "critical";
85
80
  }
@@ -104,8 +99,7 @@ export function computeCompoundReadiness(entries, options = {}) {
104
99
  qualification: criticalOverride && !meetsRecurrence ? "critical_override" : "recurrence",
105
100
  ...(bucket.severity ? { severity: bucket.severity } : {}),
106
101
  lastSeenTs: bucket.lastSeenTs,
107
- types: Array.from(bucket.types).sort(),
108
- maturity: Array.from(bucket.maturity).sort()
102
+ types: Array.from(bucket.types).sort()
109
103
  });
110
104
  }
111
105
  ready.sort((a, b) => {
@@ -143,8 +137,8 @@ export function computeCompoundReadiness(entries, options = {}) {
143
137
  const KNOWLEDGE_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
144
138
  const KNOWLEDGE_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
145
139
  const KNOWLEDGE_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
146
- const KNOWLEDGE_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
147
- const KNOWLEDGE_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
140
+ const LEGACY_KNOWLEDGE_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
141
+ const LEGACY_KNOWLEDGE_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
148
142
  const KNOWLEDGE_SOURCE_SET = new Set([
149
143
  "stage",
150
144
  "retro",
@@ -158,19 +152,21 @@ const KNOWLEDGE_REQUIRED_KEYS = [
158
152
  "trigger",
159
153
  "action",
160
154
  "confidence",
161
- "domain",
162
155
  "stage",
163
156
  "origin_stage",
164
- "origin_run",
165
157
  "frequency",
166
- "universality",
167
- "maturity",
168
158
  "created",
169
159
  "first_seen_ts",
170
160
  "last_seen_ts",
171
161
  "project"
172
162
  ];
173
163
  const KNOWLEDGE_ALLOWED_KEYS = new Set(KNOWLEDGE_REQUIRED_KEYS);
164
+ // Legacy keys are accepted for backwards compatibility when reading historical
165
+ // knowledge entries, but no longer required for newly materialized rows.
166
+ KNOWLEDGE_ALLOWED_KEYS.add("domain");
167
+ KNOWLEDGE_ALLOWED_KEYS.add("origin_run");
168
+ KNOWLEDGE_ALLOWED_KEYS.add("universality");
169
+ KNOWLEDGE_ALLOWED_KEYS.add("maturity");
174
170
  KNOWLEDGE_ALLOWED_KEYS.add("source");
175
171
  KNOWLEDGE_ALLOWED_KEYS.add("severity");
176
172
  KNOWLEDGE_ALLOWED_KEYS.add("supersedes");
@@ -178,6 +174,28 @@ KNOWLEDGE_ALLOWED_KEYS.add("superseded_by");
178
174
  function keyAllowedInKnowledgeEntry(key) {
179
175
  return KNOWLEDGE_ALLOWED_KEYS.has(key);
180
176
  }
177
+ function normalizeParsedKnowledgeEntry(obj) {
178
+ const normalized = {
179
+ type: obj.type,
180
+ trigger: obj.trigger,
181
+ action: obj.action,
182
+ confidence: obj.confidence,
183
+ stage: obj.stage,
184
+ origin_stage: obj.origin_stage,
185
+ frequency: obj.frequency,
186
+ created: obj.created,
187
+ first_seen_ts: obj.first_seen_ts,
188
+ last_seen_ts: obj.last_seen_ts,
189
+ project: obj.project
190
+ };
191
+ if (obj.severity !== undefined) {
192
+ normalized.severity = obj.severity;
193
+ }
194
+ if (obj.source !== undefined) {
195
+ normalized.source = obj.source;
196
+ }
197
+ return normalized;
198
+ }
181
199
  function knowledgePath(projectRoot) {
182
200
  return path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
183
201
  }
@@ -198,16 +216,11 @@ function dedupeKey(entry) {
198
216
  entry.type,
199
217
  normalizeText(entry.trigger),
200
218
  normalizeText(entry.action),
201
- entry.domain === null ? "null" : normalizeText(entry.domain),
202
219
  entry.stage ?? "null",
203
220
  entry.origin_stage ?? "null",
204
- entry.origin_run === null ? "null" : normalizeText(entry.origin_run),
205
- entry.universality,
206
221
  entry.project === null ? "null" : normalizeText(entry.project),
207
222
  entry.source === undefined || entry.source === null ? "null" : entry.source,
208
- entry.severity === undefined ? "none" : entry.severity,
209
- Array.isArray(entry.supersedes) ? entry.supersedes.map(normalizeText).sort().join(",") : "none",
210
- entry.superseded_by === undefined ? "none" : normalizeText(entry.superseded_by)
223
+ entry.severity === undefined ? "none" : entry.severity
211
224
  ].join("|");
212
225
  }
213
226
  function emptyKnowledgeSnapshot() {
@@ -236,7 +249,7 @@ function parseKnowledgeSnapshot(raw) {
236
249
  malformedLines += 1;
237
250
  continue;
238
251
  }
239
- const entry = parsed;
252
+ const entry = normalizeParsedKnowledgeEntry(parsed);
240
253
  entries.push(entry);
241
254
  const key = dedupeKey(entry);
242
255
  if (!keyToIndex.has(key)) {
@@ -321,7 +334,7 @@ export function validateKnowledgeEntry(entry) {
321
334
  (typeof obj.severity !== "string" || !KNOWLEDGE_SEVERITY_SET.has(obj.severity))) {
322
335
  errors.push("severity must be one of: critical, important, suggestion.");
323
336
  }
324
- if (!isNullableString(obj.domain)) {
337
+ if (obj.domain !== undefined && !isNullableString(obj.domain)) {
325
338
  errors.push("domain must be string or null.");
326
339
  }
327
340
  if (!isNullableStage(obj.stage)) {
@@ -331,7 +344,7 @@ export function validateKnowledgeEntry(entry) {
331
344
  errors.push(`origin_stage must be one of ${FLOW_STAGES.join(", ")} or null.`);
332
345
  }
333
346
  const originRun = obj.origin_run;
334
- if (!isNullableString(originRun)) {
347
+ if (originRun !== undefined && !isNullableString(originRun)) {
335
348
  errors.push("origin_run must be string or null.");
336
349
  }
337
350
  if (typeof obj.frequency !== "number" ||
@@ -339,10 +352,12 @@ export function validateKnowledgeEntry(entry) {
339
352
  obj.frequency < 1) {
340
353
  errors.push("frequency must be an integer >= 1.");
341
354
  }
342
- if (!KNOWLEDGE_UNIVERSALITY_SET.has(obj.universality)) {
355
+ if (obj.universality !== undefined &&
356
+ (typeof obj.universality !== "string" || !LEGACY_KNOWLEDGE_UNIVERSALITY_SET.has(obj.universality))) {
343
357
  errors.push("universality must be one of: project, personal, universal.");
344
358
  }
345
- if (!KNOWLEDGE_MATURITY_SET.has(obj.maturity)) {
359
+ if (obj.maturity !== undefined &&
360
+ (typeof obj.maturity !== "string" || !LEGACY_KNOWLEDGE_MATURITY_SET.has(obj.maturity))) {
346
361
  errors.push("maturity must be one of: raw, lifted-to-rule, lifted-to-enforcement.");
347
362
  }
348
363
  for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
@@ -378,20 +393,15 @@ export function materializeKnowledgeEntry(seed, defaults = {}) {
378
393
  const now = normalizeUtcIso(defaults.nowIso ?? nowUtcIso());
379
394
  const stage = seed.stage ?? defaults.stage ?? null;
380
395
  const originStage = seed.origin_stage ?? defaults.originStage ?? stage ?? null;
381
- const originRun = seed.origin_run ?? defaults.originRun ?? null;
382
396
  const source = seed.source ?? defaults.source ?? null;
383
397
  const entry = {
384
398
  type: seed.type,
385
399
  trigger: seed.trigger.trim(),
386
400
  action: seed.action.trim(),
387
401
  confidence: seed.confidence,
388
- domain: seed.domain ?? null,
389
402
  stage,
390
403
  origin_stage: originStage,
391
- origin_run: originRun,
392
404
  frequency: seed.frequency ?? 1,
393
- universality: seed.universality ?? "project",
394
- maturity: seed.maturity ?? "raw",
395
405
  created: normalizeUtcIso(seed.created ?? now),
396
406
  first_seen_ts: normalizeUtcIso(seed.first_seen_ts ?? now),
397
407
  last_seen_ts: normalizeUtcIso(seed.last_seen_ts ?? now),
@@ -400,12 +410,6 @@ export function materializeKnowledgeEntry(seed, defaults = {}) {
400
410
  if (seed.severity !== undefined) {
401
411
  entry.severity = seed.severity;
402
412
  }
403
- if (seed.supersedes !== undefined) {
404
- entry.supersedes = seed.supersedes.map((value) => value.trim());
405
- }
406
- if (seed.superseded_by !== undefined) {
407
- entry.superseded_by = seed.superseded_by.trim();
408
- }
409
413
  if (source !== null) {
410
414
  entry.source = source;
411
415
  }
@@ -514,18 +518,6 @@ function tokenizeText(value) {
514
518
  function uniqueTokens(values) {
515
519
  return [...new Set(values)];
516
520
  }
517
- function supersededTriggerSet(entries) {
518
- const superseded = new Set();
519
- for (const entry of entries) {
520
- for (const trigger of entry.supersedes ?? []) {
521
- superseded.add(normalizeText(trigger));
522
- }
523
- }
524
- return superseded;
525
- }
526
- function isSupersededLearning(entry, supersededTriggers) {
527
- return entry.superseded_by !== undefined || supersededTriggers.has(normalizeText(entry.trigger));
528
- }
529
521
  function pathTokens(paths) {
530
522
  if (!Array.isArray(paths) || paths.length === 0)
531
523
  return [];
@@ -547,9 +539,7 @@ export async function selectRelevantLearnings(projectRoot, options = {}) {
547
539
  const limit = typeof options.limit === "number" && Number.isFinite(options.limit) && options.limit > 0
548
540
  ? Math.floor(options.limit)
549
541
  : 8;
550
- const staleTriggers = supersededTriggerSet(entries);
551
- const activeEntries = entries.filter((entry) => !isSupersededLearning(entry, staleTriggers));
552
- const ranked = activeEntries.map((entry, index) => {
542
+ const ranked = entries.map((entry, index) => {
553
543
  let score = 0;
554
544
  let stageScore = 0;
555
545
  if (stage) {
@@ -567,13 +557,9 @@ export async function selectRelevantLearnings(projectRoot, options = {}) {
567
557
  score += 1;
568
558
  if (entry.frequency >= 3)
569
559
  score += 1;
570
- if (entry.maturity === "lifted-to-enforcement")
571
- score -= 1;
572
560
  const searchable = [
573
- ...tokenizeText(entry.domain),
574
561
  ...tokenizeText(entry.trigger),
575
562
  ...tokenizeText(entry.action),
576
- ...tokenizeText(entry.origin_run),
577
563
  ...tokenizeText(entry.project)
578
564
  ];
579
565
  const searchSet = new Set(searchable);
package/dist/policy.js CHANGED
@@ -58,7 +58,7 @@ export async function policyChecks(projectRoot, options = {}) {
58
58
  const utilitySkillChecks = [
59
59
  { file: runtimeFile("skills/learnings/SKILL.md"), needle: "strict JSONL schema", name: "utility_skill:learnings:jsonl_schema" },
60
60
  { file: runtimeFile("skills/learnings/SKILL.md"), needle: "knowledge.jsonl", name: "utility_skill:learnings:jsonl_store" },
61
- { file: runtimeFile("skills/learnings/SKILL.md"), needle: "type, trigger, action, confidence, domain, stage, origin_stage, origin_run, frequency, universality, maturity, created, first_seen_ts, last_seen_ts, project", name: "utility_skill:learnings:field_order" },
61
+ { file: runtimeFile("skills/learnings/SKILL.md"), needle: "type, trigger, action, confidence, stage, origin_stage, frequency, created, first_seen_ts, last_seen_ts, project", name: "utility_skill:learnings:field_order" },
62
62
  { file: runtimeFile("skills/learnings/SKILL.md"), needle: "## Manual Actions", name: "utility_skill:learnings:manual_actions" },
63
63
  { file: runtimeFile("skills/learnings/SKILL.md"), needle: "## HARD-GATE", name: "utility_skill:learnings:hard_gate" },
64
64
  { file: runtimeFile("commands/start.md"), needle: "## Algorithm", name: "utility_command:start:algorithm" },
@@ -71,7 +71,7 @@ export async function policyChecks(projectRoot, options = {}) {
71
71
  { file: runtimeFile("skills/flow-view/SKILL.md"), needle: "## Diff Subcommand", name: "utility_skill:view:diff_section" },
72
72
  { file: runtimeFile("skills/subagent-dev/SKILL.md"), needle: "## HARD-GATE", name: "utility_skill:sdd:hard_gate" },
73
73
  { file: runtimeFile("skills/subagent-dev/SKILL.md"), needle: "## Status Contract", name: "utility_skill:sdd:status_contract" },
74
- { file: runtimeFile("skills/subagent-dev/SKILL.md"), needle: "Implementer", name: "utility_skill:sdd:implementer_template" },
74
+ { file: runtimeFile("skills/subagent-dev/SKILL.md"), needle: "slice-implementer", name: "utility_skill:sdd:implementer_template" },
75
75
  { file: runtimeFile("skills/subagent-dev/SKILL.md"), needle: "## Model & Harness Routing Notes", name: "utility_skill:sdd:routing_notes" },
76
76
  { file: runtimeFile("skills/parallel-dispatch/SKILL.md"), needle: "## HARD-GATE", name: "utility_skill:parallel:hard_gate" },
77
77
  { file: runtimeFile("skills/parallel-dispatch/SKILL.md"), needle: "Review Army", name: "utility_skill:parallel:review_army" },
@@ -89,7 +89,7 @@ export async function policyChecks(projectRoot, options = {}) {
89
89
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Task classification", name: "meta_skill:task_classification" },
90
90
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Stage quick map", name: "meta_skill:stage_quick_map" },
91
91
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Whole flow map", name: "meta_skill:whole_flow_map" },
92
- { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "retro -> compound -> archive", name: "meta_skill:closeout_chain" },
92
+ { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "post_ship_review -> archive", name: "meta_skill:closeout_chain" },
93
93
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Contextual Skill Activation", name: "meta_skill:contextual_skills" },
94
94
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Protocol Behavior", name: "meta_skill:protocol_behavior" },
95
95
  { file: runtimeFile("skills/using-cclaw/SKILL.md"), needle: "## Failure guardrails", name: "meta_skill:failure_guardrails" },
@@ -1,34 +1,19 @@
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 { exists, stripBom } from "./fs-utils.js";
5
- import { readKnowledgeSafely } from "./knowledge-store.js";
4
+ import { exists } from "./fs-utils.js";
6
5
  function activeArtifactsPath(projectRoot) {
7
6
  return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
8
7
  }
9
8
  function retroArtifactPath(projectRoot) {
10
9
  return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
11
10
  }
12
- // Fallback window for compound-entry scanning when `retroDraftedAt` /
13
- // `retroAcceptedAt` are not set (legacy runs or imports): use the retro
14
- // artifact's mtime ± 7 days. 24h was too narrow for long-running retros
15
- // that are edited over several days or runs imported from another
16
- // machine with slightly different clocks; 7 days is still tight enough
17
- // that entries from an unrelated future run are excluded.
18
- const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
19
11
  function parseIsoTimestamp(value) {
20
12
  if (!value || value.trim().length === 0)
21
13
  return null;
22
14
  const parsed = Date.parse(value);
23
15
  return Number.isFinite(parsed) ? parsed : null;
24
16
  }
25
- function inInclusiveWindow(timestamp, windowStartMs, windowEndMs) {
26
- if (windowStartMs !== null && timestamp < windowStartMs)
27
- return false;
28
- if (windowEndMs !== null && timestamp > windowEndMs)
29
- return false;
30
- return true;
31
- }
32
17
  export async function evaluateRetroGate(projectRoot, state) {
33
18
  const required = state.completedStages.includes("ship");
34
19
  const artifactFile = retroArtifactPath(projectRoot);
@@ -42,83 +27,16 @@ export async function evaluateRetroGate(projectRoot, state) {
42
27
  hasRetroArtifact = false;
43
28
  }
44
29
  }
45
- let compoundEntries = 0;
46
- let windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
47
- let windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
48
- if (hasRetroArtifact &&
49
- windowStartMs === null &&
50
- windowEndMs === null) {
51
- try {
52
- const stats = await fs.stat(artifactFile);
53
- const anchor = stats.mtimeMs;
54
- if (Number.isFinite(anchor) && anchor > 0) {
55
- windowStartMs = anchor - RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS;
56
- windowEndMs = anchor + RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS;
57
- }
58
- }
59
- catch {
60
- // fallback scan remains disabled when mtime cannot be read
61
- }
62
- }
63
- const shouldScanCompoundEvidence = windowStartMs !== null || windowEndMs !== null;
64
- if (shouldScanCompoundEvidence) {
65
- const countIfEligible = (parsed) => {
66
- if (parsed.type !== "compound") {
67
- return 0;
68
- }
69
- const created = typeof parsed.created === "string" ? parseIsoTimestamp(parsed.created) : null;
70
- if (created === null || !inInclusiveWindow(created, windowStartMs, windowEndMs)) {
71
- return 0;
72
- }
73
- const source = typeof parsed.source === "string"
74
- ? parsed.source.trim().toLowerCase()
75
- : null;
76
- const legacyRetroStage = parsed.stage === "retro";
77
- return source === "retro" || legacyRetroStage ? 1 : 0;
78
- };
79
- try {
80
- const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
81
- const { entries } = await readKnowledgeSafely(projectRoot);
82
- for (const parsed of entries) {
83
- compoundEntries += countIfEligible(parsed);
84
- }
85
- // Backward compatibility for historical/hand-edited rows that don't pass
86
- // strict knowledge schema validation but still carry retro evidence.
87
- if (compoundEntries === 0 && (await exists(knowledgeFile))) {
88
- const raw = stripBom(await fs.readFile(knowledgeFile, "utf8"));
89
- for (const line of raw.split(/\r?\n/)) {
90
- const trimmed = line.trim();
91
- if (!trimmed)
92
- continue;
93
- try {
94
- const parsed = JSON.parse(trimmed);
95
- compoundEntries += countIfEligible(parsed);
96
- }
97
- catch {
98
- // ignore malformed lines for retro gate calculation
99
- }
100
- }
101
- }
102
- }
103
- catch {
104
- compoundEntries = 0;
105
- }
106
- }
107
- // A retro is considered complete when any of:
108
- // - the retro artifact exists AND (at least one compound learning was
109
- // promoted during the retro window OR compound was explicitly skipped
110
- // after reviewing the draft), or
111
- // - the operator explicitly skipped the retro step itself
112
- // (`retroSkipped === true` with a non-empty reason). `retroSkipped` is an
113
- // operator-level override of the artifact requirement, so it must
114
- // bypass `hasRetroArtifact` — otherwise a run that legitimately had
115
- // nothing worth retro-ing dead-locks at closeout waiting for a
116
- // file that will never exist.
117
30
  const retroSkipReason = state.closeout.retroSkipReason?.trim() ?? "";
118
31
  const retroSkipped = state.closeout.retroSkipped === true && retroSkipReason.length > 0;
32
+ const retroAccepted = hasRetroArtifact && parseIsoTimestamp(state.closeout.retroAcceptedAt) !== null;
119
33
  const compoundSkipped = state.closeout.compoundSkipped === true;
120
- const artifactPathComplete = hasRetroArtifact && (compoundEntries > 0 || compoundSkipped);
121
- const completed = required ? retroSkipped || artifactPathComplete : true;
34
+ const compoundReviewed = parseIsoTimestamp(state.closeout.compoundCompletedAt) !== null ||
35
+ state.closeout.compoundPromoted > 0;
36
+ const compoundEntries = compoundReviewed ? Math.max(0, Math.floor(state.closeout.compoundPromoted)) : 0;
37
+ // Keep retro-gate deterministic from closeout state only:
38
+ // retroComplete = (retroAccepted || retroSkipped) && (compoundReviewed || compoundSkipped)
39
+ const completed = required ? (retroAccepted || retroSkipped) && (compoundReviewed || compoundSkipped) : true;
122
40
  return {
123
41
  required,
124
42
  completed,
@@ -17,12 +17,10 @@ const STATE_SNAPSHOT_EXCLUDE = new Set([
17
17
  ]);
18
18
  const DELEGATION_LOG_FILE = "delegation-log.json";
19
19
  const TDD_CYCLE_LOG_FILE = "tdd-cycle-log.jsonl";
20
- const RECONCILIATION_NOTICES_FILE = "reconciliation-notices.json";
21
20
  const CRITICAL_STATE_SNAPSHOT_FILES = new Set([
22
21
  "flow-state.json",
23
22
  DELEGATION_LOG_FILE,
24
- TDD_CYCLE_LOG_FILE,
25
- RECONCILIATION_NOTICES_FILE
23
+ TDD_CYCLE_LOG_FILE
26
24
  ]);
27
25
  function archiveRoot(projectRoot) {
28
26
  return path.join(projectRoot, ARCHIVE_DIR_REL_PATH);
@@ -89,7 +87,6 @@ async function resetCarryoverStateFiles(projectRoot, activeRunId) {
89
87
  await ensureDir(stateDir);
90
88
  await writeFileSafe(path.join(stateDir, DELEGATION_LOG_FILE), `${JSON.stringify({ runId: activeRunId, entries: [] }, null, 2)}\n`, { mode: 0o600 });
91
89
  await writeFileSafe(path.join(stateDir, TDD_CYCLE_LOG_FILE), "", { mode: 0o600 });
92
- await writeFileSafe(path.join(stateDir, RECONCILIATION_NOTICES_FILE), `${JSON.stringify({ schemaVersion: 1, notices: [] }, null, 2)}\n`, { mode: 0o600 });
93
90
  }
94
91
  async function restoreStateSnapshot(projectRoot, archiveStatePath) {
95
92
  if (!(await exists(archiveStatePath)))
@@ -17,11 +17,6 @@ export class InvalidStageTransitionError extends Error {
17
17
  const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
18
18
  const ARCHIVE_DIR_REL_PATH = `${RUNTIME_ROOT}/archive`;
19
19
  const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
20
- const RECONCILIATION_NOTICES_REL_PATH = `${RUNTIME_ROOT}/state/reconciliation-notices.json`;
21
- const RECONCILIATION_NOTICES_LOCK_REL_PATH = `${RUNTIME_ROOT}/state/.reconciliation-notices.lock`;
22
- const RECONCILIATION_NOTICES_SCHEMA_VERSION = 1;
23
- const CLOSEOUT_SUBSTATE_DEMOTION_KIND = "closeout_substate_demotion";
24
- const CLOSEOUT_SUBSTATE_GATE_ID = "closeout.shipSubstate";
25
20
  const FLOW_STAGE_SET = new Set(FLOW_STAGES);
26
21
  function validateFlowTransition(prev, next) {
27
22
  if (prev.activeRunId !== next.activeRunId) {
@@ -74,88 +69,12 @@ function flowStatePath(projectRoot) {
74
69
  function flowStateLockPath(projectRoot) {
75
70
  return path.join(projectRoot, RUNTIME_ROOT, "state", ".flow-state.lock");
76
71
  }
77
- function reconciliationNoticesPath(projectRoot) {
78
- return path.join(projectRoot, RECONCILIATION_NOTICES_REL_PATH);
79
- }
80
- function reconciliationNoticesLockPath(projectRoot) {
81
- return path.join(projectRoot, RECONCILIATION_NOTICES_LOCK_REL_PATH);
82
- }
83
72
  function archiveRoot(projectRoot) {
84
73
  return path.join(projectRoot, ARCHIVE_DIR_REL_PATH);
85
74
  }
86
75
  function activeArtifactsPath(projectRoot) {
87
76
  return path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH);
88
77
  }
89
- function asObjectRecord(value) {
90
- if (!value || typeof value !== "object" || Array.isArray(value))
91
- return null;
92
- return value;
93
- }
94
- async function appendCloseoutSubstateDemotionNotice(projectRoot, state, demotion) {
95
- await withDirectoryLock(reconciliationNoticesLockPath(projectRoot), async () => {
96
- const filePath = reconciliationNoticesPath(projectRoot);
97
- const existingNotices = [];
98
- if (await exists(filePath)) {
99
- try {
100
- const raw = JSON.parse(await fs.readFile(filePath, "utf8"));
101
- const parsed = asObjectRecord(raw);
102
- const noticesRaw = parsed?.notices;
103
- if (Array.isArray(noticesRaw)) {
104
- for (const notice of noticesRaw) {
105
- const typed = asObjectRecord(notice);
106
- if (typed)
107
- existingNotices.push(typed);
108
- }
109
- }
110
- }
111
- catch {
112
- // Keep going with an empty payload; sync can still report parse errors.
113
- }
114
- }
115
- const alreadyRecorded = existingNotices.some((notice) => {
116
- if (notice.runId !== state.activeRunId)
117
- return false;
118
- if (notice.kind !== CLOSEOUT_SUBSTATE_DEMOTION_KIND)
119
- return false;
120
- const payload = asObjectRecord(notice.payload);
121
- return payload?.previous === demotion.previous &&
122
- payload?.next === demotion.next &&
123
- payload?.reason === demotion.reason;
124
- });
125
- if (alreadyRecorded)
126
- return;
127
- const ts = new Date().toISOString();
128
- existingNotices.push({
129
- id: `${state.activeRunId}:${state.currentStage}:${CLOSEOUT_SUBSTATE_GATE_ID}:${CLOSEOUT_SUBSTATE_DEMOTION_KIND}:${ts}`,
130
- runId: state.activeRunId,
131
- stage: state.currentStage,
132
- gateId: CLOSEOUT_SUBSTATE_GATE_ID,
133
- reason: demotion.reason,
134
- demotedAt: ts,
135
- kind: CLOSEOUT_SUBSTATE_DEMOTION_KIND,
136
- payload: {
137
- previous: demotion.previous,
138
- next: demotion.next,
139
- reason: demotion.reason
140
- }
141
- });
142
- existingNotices.sort((left, right) => {
143
- const leftTs = typeof left.demotedAt === "string" ? left.demotedAt : "";
144
- const rightTs = typeof right.demotedAt === "string" ? right.demotedAt : "";
145
- if (leftTs === rightTs) {
146
- const leftId = typeof left.id === "string" ? left.id : "";
147
- const rightId = typeof right.id === "string" ? right.id : "";
148
- return leftId.localeCompare(rightId);
149
- }
150
- return leftTs.localeCompare(rightTs);
151
- });
152
- await ensureDir(path.dirname(filePath));
153
- await writeFileSafe(filePath, `${JSON.stringify({
154
- schemaVersion: RECONCILIATION_NOTICES_SCHEMA_VERSION,
155
- notices: existingNotices
156
- }, null, 2)}\n`, { mode: 0o600 });
157
- });
158
- }
159
78
  function isFlowStage(value) {
160
79
  return typeof value === "string" && FLOW_STAGE_SET.has(value);
161
80
  }
@@ -338,14 +257,20 @@ function sanitizeRetroState(value) {
338
257
  function isShipSubstate(value) {
339
258
  return typeof value === "string" && SHIP_SUBSTATES.includes(value);
340
259
  }
341
- function sanitizeCloseoutState(value, onDemotion) {
260
+ function sanitizeCloseoutState(value) {
342
261
  const fallback = createInitialCloseoutState();
343
262
  if (!value || typeof value !== "object" || Array.isArray(value)) {
344
263
  return fallback;
345
264
  }
346
265
  const typed = value;
347
- let shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
348
- const previousShipSubstate = shipSubstate;
266
+ const rawShipSubstate = typeof typed.shipSubstate === "string" ? typed.shipSubstate : undefined;
267
+ let shipSubstate;
268
+ if (rawShipSubstate === "retro_review" || rawShipSubstate === "compound_review") {
269
+ shipSubstate = "post_ship_review";
270
+ }
271
+ else {
272
+ shipSubstate = isShipSubstate(rawShipSubstate) ? rawShipSubstate : fallback.shipSubstate;
273
+ }
349
274
  const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
350
275
  const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
351
276
  const retroSkipReason = typeof typed.retroSkipReason === "string"
@@ -370,21 +295,8 @@ function sanitizeCloseoutState(value, onDemotion) {
370
295
  // the compound leg, which would let `archive` skip durable closeout proof.
371
296
  const retroDone = retroAcceptedAt !== undefined || retroSkipped === true;
372
297
  const compoundDone = compoundCompletedAt !== undefined || compoundPromoted > 0 || compoundSkipped === true;
373
- let demotionReason;
374
- if (!retroDone && (shipSubstate === "ready_to_archive" || shipSubstate === "compound_review")) {
375
- shipSubstate = "retro_review";
376
- demotionReason = "retro closeout leg is incomplete (missing retroAcceptedAt or explicit retro skip).";
377
- }
378
- else if (shipSubstate === "ready_to_archive" && !compoundDone) {
379
- shipSubstate = "compound_review";
380
- demotionReason = "compound closeout leg is incomplete (missing compound proof).";
381
- }
382
- if (demotionReason && previousShipSubstate !== shipSubstate) {
383
- onDemotion?.({
384
- previous: previousShipSubstate,
385
- next: shipSubstate,
386
- reason: demotionReason
387
- });
298
+ if (shipSubstate === "ready_to_archive" && (!retroDone || !compoundDone)) {
299
+ shipSubstate = "post_ship_review";
388
300
  }
389
301
  return {
390
302
  shipSubstate,
@@ -405,7 +317,6 @@ function coerceFlowState(parsed) {
405
317
  const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
406
318
  ? activeRunIdRaw.trim()
407
319
  : next.activeRunId;
408
- let closeoutDemotion;
409
320
  const state = {
410
321
  schemaVersion: FLOW_STATE_SCHEMA_VERSION,
411
322
  activeRunId,
@@ -418,11 +329,9 @@ function coerceFlowState(parsed) {
418
329
  staleStages: sanitizeStaleStages(parsed.staleStages),
419
330
  rewinds: sanitizeRewinds(parsed.rewinds),
420
331
  retro: sanitizeRetroState(parsed.retro),
421
- closeout: sanitizeCloseoutState(parsed.closeout, (demotion) => {
422
- closeoutDemotion = demotion;
423
- })
332
+ closeout: sanitizeCloseoutState(parsed.closeout)
424
333
  };
425
- return { state, closeoutDemotion };
334
+ return { state };
426
335
  }
427
336
  export class CorruptFlowStateError extends Error {
428
337
  statePath;
@@ -484,11 +393,7 @@ export async function readFlowState(projectRoot, options = {}) {
484
393
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
485
394
  await quarantineCorruptState(statePath, new Error("flow-state.json did not deserialize to a JSON object"));
486
395
  }
487
- const coerced = coerceFlowState(parsed);
488
- if (coerced.closeoutDemotion) {
489
- await appendCloseoutSubstateDemotionNotice(projectRoot, coerced.state, coerced.closeoutDemotion);
490
- }
491
- return coerced.state;
396
+ return coerceFlowState(parsed).state;
492
397
  }
493
398
  export async function writeFlowState(projectRoot, state, options = {}) {
494
399
  const doWrite = async () => {
@@ -0,0 +1,3 @@
1
+ import { type NodeHookRuntimeOptions } from "../content/node-hooks.js";
2
+ export declare function buildRunHookRuntimeScript(options?: NodeHookRuntimeOptions): string;
3
+ export default buildRunHookRuntimeScript;
@@ -0,0 +1,5 @@
1
+ import { nodeHookRuntimeScript } from "../content/node-hooks.js";
2
+ export function buildRunHookRuntimeScript(options = {}) {
3
+ return nodeHookRuntimeScript(options);
4
+ }
5
+ export default buildRunHookRuntimeScript;