session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -44,6 +44,17 @@
44
44
  * @property {string} created_at - ISO timestamp.
45
45
  * @property {string|null} processed_at - terminal stamp (mirrors repair store).
46
46
  * @property {string|null} superseded_by
47
+ * @property {string|null} [outcome] - terminal disposition, one of:
48
+ * `'written'` (writer.mjs persisted the rule file), `'already-on-disk'`
49
+ * (engine.mjs found a matching `.claude/rules/` provenance block before
50
+ * proposing — issue #484), or `'rejected'` (the operator declined the
51
+ * proposal in the approval AUQ and writer.mjs archived it — issue #1042).
52
+ * Optional and additive: `isCandidateShape` only requires `learning_key`
53
+ * + `created_at`, so an older record without this field still round-trips
54
+ * through the store unchanged, and nothing BRANCHES on the value —
55
+ * terminality is `processed_at` alone (see {@link isProcessed}), so a
56
+ * pre-#1042 record with no `outcome` keeps behaving exactly as before
57
+ * (absence reads as `'written'`, the only outcome that existed then).
47
58
  */
48
59
 
49
60
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
@@ -265,7 +276,15 @@ export function loadCandidates({ repoRoot, storePath } = {}) {
265
276
  * True iff `existing` already holds a candidate that shares `candidate`'s
266
277
  * `learning_key` AND has a terminal `processed_at` stamp. The reconcile engine
267
278
  * uses this to idempotently SKIP re-proposing a learning whose verdict is
268
- * already terminal. Never throws.
279
+ * already terminal.
280
+ *
281
+ * Deliberately OUTCOME-AGNOSTIC: `processed_at` alone decides. A rule the
282
+ * operator DECLINED (`outcome: 'rejected'`, issue #1042) is therefore just as
283
+ * terminal as one that was written — an explicit "no" is a verdict, and
284
+ * re-asking every run is how it gets forgotten. By the same token a pre-#1042
285
+ * record carrying no `outcome` at all keeps its old meaning unchanged.
286
+ *
287
+ * Never throws.
269
288
  * @param {ReconcileCandidate} candidate - the candidate under consideration.
270
289
  * @param {ReconcileCandidate[]} existing - the currently-persisted candidates.
271
290
  * @returns {boolean}
@@ -337,3 +356,85 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
337
356
  const result = writeStore(absPath, merged);
338
357
  return { merged, written: result.ok === true, skipped };
339
358
  }
359
+
360
+ /**
361
+ * Stamp the persisted candidate for `learningKey` as terminally processed and
362
+ * write it back through {@link mergeCandidates} — the ONLY writer of this
363
+ * store, so this function opens no second write path and no new persisted
364
+ * format: `outcome` is an ordinary field alongside `processed_at`, and the
365
+ * read-side shape guard (`isCandidateShape`) only requires `learning_key` +
366
+ * `created_at`, so it accepts the stamped record unchanged.
367
+ *
368
+ * Looks up the existing record by `learningKey` first, so the write preserves
369
+ * everything the run that proposed it originally recorded (`slug`, `reason`,
370
+ * `confidence`, `created_at`) and only ADDS the terminal stamp. When no record
371
+ * exists yet — e.g. the proposing run used `dryRun` and never merged into the
372
+ * store, or `engine.mjs` discovered the on-disk `.claude/rules/` file directly
373
+ * without ever having proposed it in a prior run — a fresh terminal record is
374
+ * minted from the caller-supplied `fallback*` fields so a LATER run can still
375
+ * dedupe against it.
376
+ *
377
+ * `mergeCandidates`'s own dedupe rule still applies underneath this call: if
378
+ * the existing record is ALREADY terminal (a prior `processed_at`), the merge
379
+ * keeps the existing verdict and this call is a no-op on disk — a terminal
380
+ * verdict is never regressed, even by a second stamp attempt.
381
+ *
382
+ * Never throws (delegates to the never-throwing `loadCandidates`/
383
+ * `mergeCandidates`); on write failure returns `{ written: false, ... }`.
384
+ *
385
+ * @param {Object} [params]
386
+ * @param {string} [params.learningKey] - required; a missing/empty key is a no-op (`{written:false, stamped:null}`).
387
+ * @param {'written'|'already-on-disk'|'rejected'|string} [params.outcome]
388
+ * @param {string} [params.processedAt] - ISO timestamp; defaults to `new Date().toISOString()`.
389
+ * @param {string} [params.fallbackSlug] - used only when no existing record is found.
390
+ * @param {string} [params.fallbackCandidateId] - used only when no existing record is found; defaults to `makeCandidateId(learningKey, fallbackSlug)`.
391
+ * @param {number} [params.fallbackConfidence] - used only when no existing record is found.
392
+ * @param {string} [params.repoRoot]
393
+ * @param {string} [params.storePath]
394
+ * @returns {{ written: boolean, stamped: ReconcileCandidate|null }}
395
+ */
396
+ export function markCandidateProcessed({
397
+ learningKey,
398
+ outcome,
399
+ processedAt,
400
+ fallbackSlug,
401
+ fallbackCandidateId,
402
+ fallbackConfidence,
403
+ repoRoot,
404
+ storePath,
405
+ } = {}) {
406
+ if (typeof learningKey !== 'string' || learningKey.length === 0) {
407
+ return { written: false, stamped: null };
408
+ }
409
+
410
+ const stampAt =
411
+ typeof processedAt === 'string' && processedAt.length > 0 ? processedAt : new Date().toISOString();
412
+
413
+ const { records: existing } = loadCandidates({ repoRoot, storePath });
414
+ const found = existing.find((rec) => rec && rec.learning_key === learningKey);
415
+
416
+ /** @type {ReconcileCandidate} */
417
+ let stamped;
418
+ if (found) {
419
+ stamped = { ...found, processed_at: stampAt, outcome: typeof outcome === 'string' ? outcome : (found.outcome ?? null) };
420
+ } else {
421
+ const slug = typeof fallbackSlug === 'string' ? fallbackSlug : '';
422
+ stamped = buildCandidate({
423
+ id:
424
+ typeof fallbackCandidateId === 'string' && fallbackCandidateId.length > 0
425
+ ? fallbackCandidateId
426
+ : makeCandidateId(learningKey, slug),
427
+ learningKey,
428
+ slug,
429
+ status: 'proposed',
430
+ reason: 'stamped without a prior sidecar record',
431
+ confidence: typeof fallbackConfidence === 'number' ? fallbackConfidence : 0,
432
+ createdAt: stampAt,
433
+ });
434
+ stamped.processed_at = stampAt;
435
+ stamped.outcome = typeof outcome === 'string' ? outcome : null;
436
+ }
437
+
438
+ const result = mergeCandidates({ candidates: [stamped], repoRoot, storePath });
439
+ return { written: result.written === true, stamped };
440
+ }
@@ -43,6 +43,8 @@
43
43
 
44
44
  import { createHash } from 'node:crypto';
45
45
 
46
+ import yaml from 'js-yaml';
47
+
46
48
  import { kebab } from '../learnings/kebab.mjs';
47
49
  import {
48
50
  EVIDENCE_ITEM_MAX_BYTES,
@@ -64,6 +66,10 @@ import {
64
66
  /**
65
67
  * Short, stable SHA-1 prefix (first 7 hex chars) of an input string.
66
68
  *
69
+ * Slug-collision helper ONLY. Deliberately NOT reused for the evidence digest
70
+ * below (#1101): 7 hex chars of SHA-1 is a namespacing device, not a tamper
71
+ * seal, and conflating the two would let a slug change read as a seal break.
72
+ *
67
73
  * @param {string} input
68
74
  * @returns {string}
69
75
  */
@@ -71,6 +77,117 @@ function shortHash(input) {
71
77
  return createHash('sha1').update(String(input)).digest('hex').slice(0, 7);
72
78
  }
73
79
 
80
+ /**
81
+ * Shape of the `evidence-digest` frontmatter scalar (#1101).
82
+ *
83
+ * `sha256-v1:` names BOTH the algorithm and the canonical-input version, so a
84
+ * future change to the input recipe bumps the prefix rather than silently
85
+ * invalidating every seal on disk.
86
+ *
87
+ * A LITERAL COPY of this regex lives in `skills/claude-md-drift-check/checker.mjs`
88
+ * (Check 8). The checker is a standalone skill script; importing across the
89
+ * skill/script boundary would be new coupling for one 30-character literal.
90
+ * `tests/lib/reconcile/renderer.test.mjs` pins the two copies equal.
91
+ */
92
+ export const EVIDENCE_DIGEST_RE = /^sha256-v1:[0-9a-f]{64}$/;
93
+
94
+ /**
95
+ * Shape of the `evidence-recorded-at` provenance value — an ISO-8601 instant.
96
+ *
97
+ * A learning's `created_at` is machine-written by the learnings store, but it
98
+ * reaches this renderer on an agent-authored record, and it lands on a body
99
+ * line OUTSIDE an inline-code span. A value carrying a newline could forge a
100
+ * sibling provenance line, so a non-conforming value is treated as ABSENT (the
101
+ * empty-string digest component) rather than interpolated. Declining to record
102
+ * an unusable timestamp is the same posture as the three backfill cases whose
103
+ * `created_at` is genuinely unavailable — an invented date would make the
104
+ * digest a reference masquerading as evidence.
105
+ */
106
+ const EVIDENCE_RECORDED_AT_RE = /^\d{4}-\d{2}-\d{2}T[0-9:.]+(?:Z|[+-]\d{2}:\d{2})$/;
107
+
108
+ /**
109
+ * Compute the `sha256-v1:` evidence digest from its six canonical components.
110
+ *
111
+ * ── Why this is EVIDENCE and not a REFERENCE (#1101) ─────────────────────────
112
+ * `.orchestrator/metrics/learnings.jsonl` is gitignored, so in a fresh clone a
113
+ * rule's `learning-key` resolves to nothing and its provenance is unverifiable.
114
+ * The last component here is the RENDERED `## Evidence` block itself — so a
115
+ * reader holding only the `.md` file re-reads its own `## Evidence` section,
116
+ * re-joins the five header fields from its own `## Provenance` block, hashes,
117
+ * and compares. `learnings.jsonl` is never consulted.
118
+ *
119
+ * The input is the six components joined with `\n`, UTF-8, **no trailing
120
+ * newline**. Order is fixed and versioned by the `sha256-v1:` prefix.
121
+ *
122
+ * @param {object} parts
123
+ * @param {string} parts.learningKey
124
+ * @param {string} parts.learningId - the same `'n/a'` fallback the body emits.
125
+ * @param {string} parts.sourceSession - ditto.
126
+ * @param {string} parts.evidenceRecordedAt - the learning's `created_at`, or
127
+ * `''` when absent/unusable. NEVER a substituted or invented date.
128
+ * @param {string} parts.confidence - `String(metadata.confidence)`.
129
+ * @param {string} parts.evidenceBlock - the EXACT string written to
130
+ * `## Evidence`, verbatim.
131
+ * @returns {string} `sha256-v1:<64 lowercase hex>`
132
+ */
133
+ export function computeEvidenceDigest({
134
+ learningKey,
135
+ learningId,
136
+ sourceSession,
137
+ evidenceRecordedAt,
138
+ confidence,
139
+ evidenceBlock,
140
+ }) {
141
+ const canonical = [
142
+ String(learningKey ?? ''),
143
+ String(learningId ?? ''),
144
+ String(sourceSession ?? ''),
145
+ String(evidenceRecordedAt ?? ''),
146
+ String(confidence ?? ''),
147
+ String(evidenceBlock ?? ''),
148
+ ].join('\n');
149
+ return `sha256-v1:${createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
150
+ }
151
+
152
+ /**
153
+ * Serialize a single-line frontmatter scalar the way real YAML requires it
154
+ * (#1041), while staying parseable by `rule-loader.mjs`'s hand-rolled parser.
155
+ *
156
+ * `description` is the one frontmatter field {@link assertSafeDescription}
157
+ * permits to contain a colon (14 live rules carry a second `:` in their
158
+ * description, and this repo's own loader tolerates it — see `sanitize.mjs`'s
159
+ * module doc). This repo's loader is not the only reader: Claude Code's OWN
160
+ * native frontmatter loader parses `.claude/rules/*.md` as real YAML, and
161
+ * `description: fixes X: this breaks Y` emitted unquoted is a YAML mapping
162
+ * with an extra, unindented `this breaks Y` value — js-yaml throws "bad
163
+ * indentation of a mapping entry" on exactly that shape.
164
+ *
165
+ * `yaml.dump` emits a bare plain scalar when no quoting is needed (byte-
166
+ * identical to the un-quoted output this renderer shipped before #1041, so
167
+ * every colon-free description stays unchanged) and falls back to a quoted
168
+ * scalar only when required. js-yaml's DEFAULT quoting style (no
169
+ * `quotingType` option passed) is SINGLE-quote, not double — confirmed via
170
+ * `node_modules/js-yaml/lib/dumper.js` `chooseScalarStyle`, which only forces
171
+ * DOUBLE for a string containing a non-printable code point. A single-quoted
172
+ * scalar is what `rule-loader.mjs`'s hand-rolled parser round-trips
173
+ * correctly: it strips exactly one leading/trailing quote character
174
+ * (`/^["']|["']$/g`) with NO escape processing, so a js-yaml DOUBLE-quoted
175
+ * `\"` escape would survive the strip verbatim and corrupt the parsed value —
176
+ * single-quote style has no such escape to survive (its only escape, an
177
+ * interior `'` doubled to `''`, is a narrower edge case than the colon defect
178
+ * this fix targets and is out of scope here). `description` can never contain
179
+ * a raw newline by the time this runs — {@link assertSafeDescription} /
180
+ * {@link assertNoControlChars} reject one earlier in the same call — so the
181
+ * literal/folded block-scalar branches `yaml.dump` has for multi-line input
182
+ * are unreachable on this call site.
183
+ *
184
+ * @param {string} value
185
+ * @returns {string} a single YAML scalar line, no trailing newline
186
+ */
187
+ function dumpYamlScalar(value) {
188
+ return yaml.dump(value, { lineWidth: -1 }).trimEnd();
189
+ }
190
+
74
191
  /**
75
192
  * Derive a kebab-case, stable, idempotent slug for a learning's rule file.
76
193
  *
@@ -253,7 +370,9 @@ export function renderRule(learning, metadata) {
253
370
  fm.push('---');
254
371
  fm.push('auto-generated: true');
255
372
  fm.push('alwaysApply: false');
256
- fm.push(`description: ${metadata.description}`);
373
+ // #1041: js-yaml-safe serialization — see `dumpYamlScalar` above. Byte-
374
+ // identical to the prior unquoted output for every colon-free description.
375
+ fm.push(`description: ${dumpYamlScalar(metadata.description)}`);
257
376
  // OMIT the `globs:` key entirely when there are no globs (a host-class-only
258
377
  // activation axis). An empty `globs:` line parses in the loader as
259
378
  // `globs: []`, which short-circuits BEFORE the host-class gate runs
@@ -287,7 +406,6 @@ export function renderRule(learning, metadata) {
287
406
  fm.push(`learning-key: ${metadata.learningKey}`);
288
407
  fm.push(`confidence: ${metadata.confidence}`);
289
408
  fm.push(`expires-at: ${metadata.expiresAt}`);
290
- fm.push('---');
291
409
 
292
410
  // ── Body (free markdown; does not affect frontmatter parsing) ────────────
293
411
  // Both provenance values are rendered INSIDE an inline-code span below, which
@@ -306,6 +424,28 @@ export function renderRule(learning, metadata) {
306
424
  })
307
425
  : 'n/a';
308
426
 
427
+ // ── Evidence digest (#1101) ──────────────────────────────────────────────
428
+ // Rendered ONCE and reused, so the hashed bytes and the written bytes are the
429
+ // same string by construction rather than by two calls agreeing.
430
+ const evidenceBlock = renderEvidence(learning.evidence);
431
+ const evidenceRecordedAt =
432
+ typeof learning.created_at === 'string' && EVIDENCE_RECORDED_AT_RE.test(learning.created_at)
433
+ ? learning.created_at
434
+ : '';
435
+ const evidenceDigest = computeEvidenceDigest({
436
+ learningKey: metadata.learningKey,
437
+ learningId,
438
+ sourceSession,
439
+ evidenceRecordedAt,
440
+ confidence: String(metadata.confidence),
441
+ evidenceBlock,
442
+ });
443
+ // Appended AFTER `expires-at` on purpose: every key BEFORE it keeps its byte
444
+ // position, so the frontmatter of an existing rule stays byte-stable and the
445
+ // digest is purely additive.
446
+ fm.push(`evidence-digest: ${evidenceDigest}`);
447
+ fm.push('---');
448
+
309
449
  // The untrusted region (H1 + insight + Evidence) is FRAMED: the envelope
310
450
  // states, in machine-generated text the record cannot forge, that everything
311
451
  // inside is agent-authored data rather than an instruction to the reading
@@ -319,7 +459,7 @@ export function renderRule(learning, metadata) {
319
459
  insight,
320
460
  '',
321
461
  '## Evidence',
322
- renderEvidence(learning.evidence),
462
+ evidenceBlock,
323
463
  '',
324
464
  UNTRUSTED_END,
325
465
  '',
@@ -328,6 +468,11 @@ export function renderRule(learning, metadata) {
328
468
  `- learning-key: \`${metadata.learningKey}\``,
329
469
  `- learning-id: \`${learningId}\``,
330
470
  `- source-session: \`${sourceSession}\``,
471
+ // #1101: the seal, its one un-derivable input, and the recipe to re-check
472
+ // both — everything an offline reader needs, in the file itself.
473
+ `- evidence-digest: \`${evidenceDigest}\``,
474
+ `- evidence-recorded-at: ${evidenceRecordedAt}`,
475
+ '- evidence-digest-input: learning-key \\n learning-id \\n source-session \\n evidence-recorded-at \\n confidence \\n <the `## Evidence` block above, verbatim UTF-8, LF-joined, no trailing newline>',
331
476
  `- confidence: ${metadata.confidence}`,
332
477
  '- generated-by: reconciliation-engine (Epic #693 FA2 / #695)',
333
478
  `- expires-at: ${metadata.expiresAt}`,
@@ -43,21 +43,40 @@
43
43
  * throw to an audited `emit/render error: …` rejection, never a crash.
44
44
  *
45
45
  * -- What this module deliberately does NOT touch -----------------------------
46
- * Colons (14 live `description:` lines carry a second one, and the loader's
47
- * first-colon split at rule-loader.mjs handles them), non-ASCII punctuation
48
- * (`—` is content; the trailing `…` is the emitter's own truncation marker),
49
- * `*`/`**` inside globs (already double-quoted by the renderer), body `---`
50
- * horizontal rules (a WRAPPER-side concern in print-applicable-rules.mjs, not
51
- * ours), fenced code blocks, markdown headings, inline backticks and HTML
52
- * comments in the body those ARE the reconciler's own output shape.
53
- *
54
- * The frontmatter parser this defends against is HAND-ROLLED, not a YAML
55
- * library (`rule-loader.mjs` imports only node:fs/path/module): a `:` in a
56
- * scalar is harmless, a mid-line `#` is harmless, quotes survive — the ONE
57
- * escape is a newline, which starts a new top-level key. That is why the
58
- * machine-value asserts below are shaped as "no control characters / exact
59
- * token shape" and NOT as YAML quoting or colon escaping, which would be both
60
- * wrong and destructive here.
46
+ * Colons in general (a mid-line `#`), non-ASCII punctuation (`—` is content;
47
+ * the trailing `…` is the emitter's own truncation marker), `*`/`**` inside
48
+ * globs (already double-quoted by the renderer), body `---` horizontal rules
49
+ * (a WRAPPER-side concern in print-applicable-rules.mjs, not ours), fenced
50
+ * code blocks, markdown headings, inline backticks and HTML comments in the
51
+ * body those ARE the reconciler's own output shape, and this module's SHAPE
52
+ * asserts below are unaffected either way: they reject on control characters,
53
+ * dangerous invisibles, wrapper-forgery literals and length, never on colons.
54
+ *
55
+ * The frontmatter parser THIS module's asserts are shaped for is HAND-ROLLED,
56
+ * not a YAML library (`rule-loader.mjs` imports only node:fs/path/module): a
57
+ * `:` in a scalar is harmless there, a mid-line `#` is harmless, quotes
58
+ * survive the ONE escape is a newline, which starts a new top-level key.
59
+ * That is why the machine-value asserts below are shaped as "no control
60
+ * characters / exact token shape" and NOT as YAML quoting or colon escaping,
61
+ * which would be both wrong and destructive for THIS parser.
62
+ *
63
+ * That reasoning stopped being the whole story once #1041 landed: Claude
64
+ * Code's OWN native frontmatter loader — the thing that actually delivers a
65
+ * rendered rule into every future agent's context — parses the SAME file as
66
+ * real YAML, and a colon-bearing `description:` value emitted unquoted
67
+ * (`description: fixes X: this breaks Y`) is a syntax error there ("bad
68
+ * indentation of a mapping entry"), not merely a shape this module chose not
69
+ * to touch. The fix for that is NOT a new assert in this module — 14 live
70
+ * `description:` lines legitimately carry a second colon, and this module's
71
+ * job stays "reject an unsafe SHAPE", never "reformat a safe one". Instead
72
+ * `renderer.mjs`'s `dumpYamlScalar` (built on the already-installed `js-yaml`
73
+ * dependency) serializes the `description` value returned by
74
+ * {@link assertSafeDescription} as a real YAML scalar (plain when safe,
75
+ * single-quoted only when a colon or similar forces it) at the render POINT,
76
+ * after this module's shape gate has already run. The two layers stay
77
+ * distinct on purpose: this module still decides whether the value may be
78
+ * emitted at all; `renderer.mjs` decides how to spell the value both parsers
79
+ * — the hand-rolled one AND js-yaml — can read back unchanged.
61
80
  *
62
81
  * Pure functions — no file I/O, no process state. Part of issue #1015.
63
82
  *
@@ -190,8 +209,12 @@ export const PROVENANCE_TOKEN_RE = /^[A-Za-z0-9._:-]+$/;
190
209
  // byte gate. The property class keeps the source pure ASCII by construction.
191
210
  //
192
211
  // CONTROL_RE is the REJECT test for single-line machine values, where a TAB or
193
- // LF is an escape rather than formatting.
194
- const CONTROL_RE = /\p{Cc}/u;
212
+ // LF is an escape rather than formatting. U+2028/U+2029 (Zl/Zp) are NOT \p{Cc}
213
+ // but YAML 1.1 and js-yaml's loader treat them as line breaks — measured
214
+ // 2026-08-23 (W4-R2): `"safe\u2028alwaysApply: true"` passed this gate and was
215
+ // neutralised only by js-yaml's escaping in the renderer. The guarantee
216
+ // belongs in the gate, not in a downstream serializer's style choice.
217
+ const CONTROL_RE = /[\p{Cc}\u2028\u2029]/u;
195
218
 
196
219
  // The same category, global, for the prose strip. TAB/LF are re-admitted by the
197
220
  // replacement callback in `sanitizeProse` (multi-line prose is legitimate in a