session-orchestrator 3.16.0 → 3.19.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 (220) 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/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,564 @@
1
+ /**
2
+ * scope-baseline.mjs — Scope Governor: baseline-freeze + drift-tripwire module.
3
+ *
4
+ * Freezes a per-session "scope baseline" into STATE.md frontmatter (five
5
+ * flat keys) exactly once per session, reads it back, and computes a
6
+ * warn-only drift ratio against it. This is the mechanical half of the
7
+ * Scope Governor's session-scoping self-validation (PRD "Scope Governor —
8
+ * Loop-Termination über Scope statt über Retry-Cap",
9
+ * docs/prd/2026-07-25-scope-governor.md, Epic #894).
10
+ *
11
+ * SCOPE: this module implements `writeBaseline()`, `readBaseline()`, the
12
+ * shared `DRIFT_EXCLUDE_PATTERNS` constant (issue #895 / S1), and
13
+ * `computeDrift()` — the 2x-tripwire drift calculation that consumes
14
+ * `DRIFT_EXCLUDE_PATTERNS` from both sides of the ratio (issue #896 / S2,
15
+ * delivered in this revision). The two distinguishable non-baseline
16
+ * `readBaseline()` return shapes (`null` vs `{ stale: true, ... }`) exist
17
+ * specifically so `computeDrift()` can pick its skip-reason additively
18
+ * without re-parsing STATE.md a second time: both functions share the
19
+ * internal `readFrontmatterOrReason()` + `classifyBaselineFromFrontmatter()`
20
+ * helpers below, so a single `computeDrift()` call performs exactly ONE
21
+ * `readFileSync`/`parseStateMd` pass over STATE.md.
22
+ *
23
+ * ---------------------------------------------------------------------
24
+ * Session-scoping semantics (the load-bearing part).
25
+ * ---------------------------------------------------------------------
26
+ * The canonical frontmatter key is `session` — NOT `session-id`. See
27
+ * skills/_shared/state-ownership.md:28 ("optional... writers SHOULD
28
+ * populate these fields but readers MUST tolerate their absence"). This
29
+ * module reads `frontmatter.session` directly; it does NOT call
30
+ * `parseSessionId(repoRoot)` — that function takes a session-ID STRING and
31
+ * performs no I/O (scripts/lib/session-id.mjs:21/200, "pure synchronous
32
+ * function — no I/O, no side effects"). The frontmatter read lives in that
33
+ * module's PRIVATE, unexported `readSessionIdFromStateMd()` helper, not in
34
+ * `parseSessionId()` itself.
35
+ *
36
+ * Three rules govern `scope-baseline-session` vs. the current `session`:
37
+ * 1. No `scope-baseline-session` key present at all → this STATE.md has
38
+ * never had a baseline frozen. Write unconditionally — even when the
39
+ * current `session` field is itself absent (freezes `null`).
40
+ * 2. A `scope-baseline-session` key IS present (its stored value may
41
+ * itself be `null`, from rule 1) → compare it against the current
42
+ * `session` value via plain equality on normalised values:
43
+ * - equal (this also covers BOTH sides being `null` — a missing
44
+ * optional `session` field must never render the tripwire
45
+ * permanently inert by making every comparison look "stale") →
46
+ * MATCH → `{ written: false, reason: 'already-frozen' }`, no
47
+ * mutation.
48
+ * - different → the stored baseline belongs to an earlier session →
49
+ * STALE → fully overwritten, `{ written: true }`.
50
+ *
51
+ * ---------------------------------------------------------------------
52
+ * Fail-open asymmetry — deliberate exception to a documented module
53
+ * convention (naming it explicitly so a future reader does not "fix" it
54
+ * away as an oversight).
55
+ * ---------------------------------------------------------------------
56
+ * `getEnforcementLevel()` and `gateEnabled()` (scope-gate.mjs:50-57 / :68-79)
57
+ * both fall toward ENFORCEMENT on unreadable data, and
58
+ * `assertFileScopeSubset()` documents "Fail-closed & no-throw (module
59
+ * convention)" at scope-gate.mjs:195-197. THIS module inverts that: on
60
+ * unreadable or missing STATE.md, both `writeBaseline()` and
61
+ * `readBaseline()` fail OPEN (a skip-ish result, never a throw, never a
62
+ * denial). Rationale: a *blocking* gate must be suspicious of unreadable
63
+ * data and fail toward the restrictive side; a *warning* signal — this
64
+ * module feeds the S2 warn-only drift tripwire, never a Deny — must stay
65
+ * SILENT on unreadable data instead, because a WARN fired from
66
+ * corrupt/missing input is noise that erodes the guard's credibility faster
67
+ * than a missed drift warning ever would. Do not unify this back to
68
+ * fail-closed. `computeDrift()` follows the SAME asymmetry: every
69
+ * unreadable/missing/stale/unresolvable condition is a silent `skipped`
70
+ * result, never a WARN and never a thrown error.
71
+ *
72
+ * ---------------------------------------------------------------------
73
+ * Sync/async rule.
74
+ * ---------------------------------------------------------------------
75
+ * `readBaseline()` and `computeDrift()` are SYNC (`readFileSync` +, for
76
+ * `computeDrift()`, `execFileSync('git', ...)` only) — pure read paths, kept
77
+ * consistent with the scope-gate.mjs all-sync hook-hot-path convention.
78
+ * `writeBaseline()` is the ONLY async export in this module, and ONLY
79
+ * because `writeStateMd()` (scripts/lib/state-md/frontmatter-mutators.mjs:348,
80
+ * re-exported from scripts/lib/state-md.mjs) is itself async — it takes the
81
+ * STATE.md lock (`withStateMdLock()`) INTERNALLY, so `writeBaseline()` MUST
82
+ * NOT wrap its own call in a second `withStateMdLock()` (that would deadlock
83
+ * on the lockfile). Routing through `writeStateMd()` instead of a private
84
+ * lock+read+write sequence also gets `writeBaseline()` two guards for free:
85
+ * a size-ceiling check and a frontmatter-round-trip-safety check (both
86
+ * documented at frontmatter-mutators.mjs:307-328) — a breach of either
87
+ * REFUSES the write (leaves STATE.md untouched) and surfaces as
88
+ * `{ written: false, reason: 'size-ceiling' | 'frontmatter-unsafe' }`
89
+ * instead of silently bypassing the guard the way the old private
90
+ * `writeStateMdAtomic()` helper did.
91
+ *
92
+ * `writeStateMd()` never throws by contract EXCEPT a `TypeError` when its
93
+ * transformer returns a non-string/non-null/non-undefined value — this
94
+ * module's transformer always returns a string or `null`. As of issue #903
95
+ * the `plannedFiles` argument is validated BEFORE `writeStateMd()` is ever
96
+ * called (see `writeBaseline()`'s param doc) — an invalid shape now short-
97
+ * circuits with `{ written: false, reason: 'invalid-planned-files' }`
98
+ * without reading STATE.md or taking the lock at all, so the transformer
99
+ * itself no longer has a `plannedFiles`-shape failure mode to throw from.
100
+ * The underlying `withStateMdLock()` THROWS a labelled Error on acquire
101
+ * failure BY DESIGN (state-md-lock.mjs:265-270: "throws a labelled Error so
102
+ * callers see the failure as an exception rather than a silent {ok:false}
103
+ * return", throw site :328, tagging `err.code` as `STATE_LOCK_TIMEOUT` or
104
+ * `STATE_LOCK_FS_ERROR`). This module's contract is "never throw" —
105
+ * `writeBaseline()` therefore wraps the `writeStateMd()` call in try/catch
106
+ * and maps the escaping error's `err.code` HONESTLY instead of collapsing
107
+ * every failure into one reason (#894 review finding F4 — a disk-full /
108
+ * permission failure on the lock directory must not misdirect the operator
109
+ * toward "retry, it's contention"):
110
+ * `STATE_LOCK_TIMEOUT` → `'lock-timeout'`, `STATE_LOCK_FS_ERROR` →
111
+ * `'lock-fs-error'`, anything else → `'unexpected-error'` (this branch has
112
+ * no currently-reachable trigger post-#903 but stays as a defensive
113
+ * catch-all).
114
+ *
115
+ * `branch` and `session-start-ref` are READ from the existing frontmatter —
116
+ * this module never writes or duplicates them (they are already session-
117
+ * pinned values STATE.md carries independently of this feature).
118
+ *
119
+ * No I/O at import time. Node 20+ stdlib only.
120
+ */
121
+
122
+ import { readFileSync } from 'node:fs';
123
+ import { execFileSync } from 'node:child_process';
124
+
125
+ import { parseStateMd, serializeStateMd, resolveStateMdPath, writeStateMd } from './state-md.mjs';
126
+ import { pathMatchesPattern } from './scope-gate.mjs';
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // DRIFT_EXCLUDE_PATTERNS — the SINGLE filter source for both the denominator
130
+ // and the numerator, applied through the SAME internal `filterExcluded()`
131
+ // helper below (#894 review finding F1 — the denominator used to be filtered
132
+ // only in PROSE, by the coordinator, before calling `writeBaseline()`; a
133
+ // prose instruction is not a code path, so a coordinator that forgot to
134
+ // filter silently broke the ratio). `writeBaseline()` now filters the
135
+ // denominator itself, in code, whenever its `plannedFiles` argument is the
136
+ // RAW array of planned file paths (`countPlannedFiles()`, exported below —
137
+ // the ONLY accepted call shape as of issue #903, which REMOVED the
138
+ // previously-accepted "already counted, store verbatim" plain-number
139
+ // back-compat shape: it was an unverified re-entry vector for exactly the
140
+ // F1 bug this filter exists to prevent — a caller could hand
141
+ // `writeBaseline()` an unfiltered count with no `filterExcluded()` pass at
142
+ // all). `computeDrift()` filters the numerator at measure time by running
143
+ // the live `git diff --name-only` output through the same
144
+ // `filterExcluded()` helper.
145
+ // Deliberately narrows skills/session-end/plan-verification.md:42-45's
146
+ // per-session-state exclusion from "the whole .claude/ directory" down to
147
+ // just the session ARTEFACTS — `.claude/rules/**` stays COUNTED because a
148
+ // rule file is a deliverable of this very epic, not a session artefact.
149
+ // plan-verification.md:43 (test-file reclassification) is a bucket
150
+ // RECLASSIFY — the file stays counted, never excluded — so it is NOT
151
+ // mirrored here; :44 (generated/lock) and :45 (per-session state, narrowed)
152
+ // are true exclusions and ARE mirrored below.
153
+ // ---------------------------------------------------------------------------
154
+ export const DRIFT_EXCLUDE_PATTERNS = [
155
+ // Tests — excluded on BOTH sides (deliberately NOT "reclassified" the way
156
+ // plan-verification.md:43 does).
157
+ '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
158
+ // Generated / lock (covers plan-verification.md:44).
159
+ 'package-lock.json', 'pnpm-lock.yaml', '*.lock', 'dist/**', 'node_modules/**',
160
+ // Per-session state — deliberately NARROWS plan-verification.md:45 to the
161
+ // ARTEFACTS instead of the whole directory. `.claude/rules/**` stays
162
+ // COUNTED: a rule file is a deliverable, not a session artefact.
163
+ '.claude/STATE.md', '.claude/wave-scope.json', '.claude/metrics/**',
164
+ '.codex/STATE.md', '.codex/wave-scope.json', '.codex/metrics/**',
165
+ '.cursor/STATE.md', '.cursor/wave-scope.json', '.cursor/metrics/**',
166
+ '.pi/STATE.md', '.pi/wave-scope.json', '.pi/metrics/**',
167
+ ];
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Internal helpers
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Current time as an ISO-8601 UTC string.
175
+ * @returns {string}
176
+ */
177
+ function nowIso() {
178
+ return new Date().toISOString();
179
+ }
180
+
181
+ /**
182
+ * Normalise a raw frontmatter session value for comparison: a non-empty
183
+ * string is kept as-is; anything else (undefined, `null`, or any
184
+ * non-string scalar the YAML parser could in principle produce) collapses
185
+ * to `null`. This is what makes "both sides absent" compare equal via a
186
+ * plain `===` at the call sites — no special-casing needed there.
187
+ * @param {unknown} v
188
+ * @returns {string|null}
189
+ */
190
+ function normalizeSessionValue(v) {
191
+ return typeof v === 'string' && v.length > 0 ? v : null;
192
+ }
193
+
194
+ /**
195
+ * Round to 2 decimal places. Plain `Math.round` scaling — good enough for a
196
+ * human-facing ratio, not a precision-sensitive computation.
197
+ * @param {number} n
198
+ * @returns {number}
199
+ */
200
+ function round2(n) {
201
+ return Math.round(n * 100) / 100;
202
+ }
203
+
204
+ /**
205
+ * Filter a raw file-path list through `DRIFT_EXCLUDE_PATTERNS`. THE shared
206
+ * filter primitive (#894 review finding F1) — both `computeDrift()`'s
207
+ * numerator (below) and `writeBaseline()`'s denominator (via
208
+ * `countPlannedFiles()`/`filterPlannedFiles()`, exported below) call this
209
+ * SAME function. No other code path in this module is allowed to
210
+ * re-implement the `DRIFT_EXCLUDE_PATTERNS` filter loop.
211
+ * @param {string[]} files
212
+ * @returns {string[]}
213
+ */
214
+ function filterExcluded(files) {
215
+ return files.filter(
216
+ (f) => !DRIFT_EXCLUDE_PATTERNS.some((pattern) => pathMatchesPattern(f, pattern))
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Internal: read + parse STATE.md's frontmatter exactly once. Shared by
222
+ * `readBaseline()` and `computeDrift()` so a single `computeDrift()` call
223
+ * never re-parses the same file twice (see module docstring § SCOPE).
224
+ *
225
+ * @param {string|undefined} repoRoot
226
+ * @returns {{ ok: true, fm: object } | { ok: false, reason: 'no-state-md'|'unreadable-state-md' }}
227
+ */
228
+ function readFrontmatterOrReason(repoRoot) {
229
+ const statePath = resolveStateMdPath(repoRoot);
230
+ let raw;
231
+ try {
232
+ raw = readFileSync(statePath, 'utf8');
233
+ } catch {
234
+ // No STATE.md at all (e.g. persistence: false, or never yet written).
235
+ return { ok: false, reason: 'no-state-md' };
236
+ }
237
+
238
+ const parsed = parseStateMd(raw);
239
+ if (parsed === null) {
240
+ // Malformed/unparseable frontmatter — fail-open (see module docstring).
241
+ return { ok: false, reason: 'unreadable-state-md' };
242
+ }
243
+
244
+ return { ok: true, fm: parsed.frontmatter };
245
+ }
246
+
247
+ /**
248
+ * Internal: classify a scope baseline from ALREADY-PARSED frontmatter.
249
+ * Mirrors `readBaseline()`'s public contract exactly (same three shapes) —
250
+ * extracted so `computeDrift()` can reuse the identical classification
251
+ * logic against the SAME parsed frontmatter object `readFrontmatterOrReason()`
252
+ * already produced, instead of re-parsing STATE.md a second time.
253
+ *
254
+ * @param {object} fm — already-parsed STATE.md frontmatter
255
+ * @returns {null
256
+ * | { stale: true, baselineSession: string|null, currentSession: string|null }
257
+ * | { intent: unknown, ownerBoundary: unknown, plannedFiles: unknown,
258
+ * session: string|null, frozenAt: unknown, branch: unknown,
259
+ * sessionStartRef: unknown }}
260
+ */
261
+ function classifyBaselineFromFrontmatter(fm) {
262
+ if (!Object.prototype.hasOwnProperty.call(fm, 'scope-baseline-session')) {
263
+ return null; // no baseline has ever been frozen
264
+ }
265
+
266
+ const baselineSession = normalizeSessionValue(fm['scope-baseline-session']);
267
+ const currentSession = normalizeSessionValue(fm.session);
268
+
269
+ if (baselineSession !== currentSession) {
270
+ return { stale: true, baselineSession, currentSession };
271
+ }
272
+
273
+ return {
274
+ intent: fm['scope-baseline-intent'] ?? null,
275
+ ownerBoundary: fm['scope-baseline-owner-boundary'] ?? null,
276
+ plannedFiles: fm['scope-baseline-planned-files'] ?? null,
277
+ session: baselineSession,
278
+ frozenAt: fm['scope-baseline-frozen-at'] ?? null,
279
+ branch: fm.branch ?? null,
280
+ sessionStartRef: fm['session-start-ref'] ?? null,
281
+ };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Public API
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Filter a RAW file-path list (e.g. the union of declared agent file scopes
290
+ * for a session) through `DRIFT_EXCLUDE_PATTERNS`, returning the surviving
291
+ * paths. This is the denominator half of the #894 review finding F1 fix —
292
+ * `writeBaseline()` calls `countPlannedFiles()` (below), which wraps this
293
+ * function, whenever its `plannedFiles` argument is an array. Exported
294
+ * directly for callers that want the filtered LIST rather than just the
295
+ * count (e.g. for logging which planned files were excluded).
296
+ * @param {string[]} files
297
+ * @returns {string[]}
298
+ */
299
+ export function filterPlannedFiles(files) {
300
+ return filterExcluded(files);
301
+ }
302
+
303
+ /**
304
+ * Filter a RAW file-path list through `DRIFT_EXCLUDE_PATTERNS` and return
305
+ * the surviving count — the preferred way to compute `writeBaseline()`'s
306
+ * `plannedFiles` denominator. Calls the exact same `filterExcluded()`
307
+ * primitive `computeDrift()`'s numerator uses, so both sides of the S2
308
+ * drift ratio are provably produced by one function (#894 review finding
309
+ * F1).
310
+ * @param {string[]} files
311
+ * @returns {number}
312
+ */
313
+ export function countPlannedFiles(files) {
314
+ return filterPlannedFiles(files).length;
315
+ }
316
+
317
+ /**
318
+ * Read the scope baseline from STATE.md frontmatter, without mutating
319
+ * anything. Sync, never throws.
320
+ *
321
+ * @param {string|undefined} repoRoot
322
+ * @returns {null
323
+ * | { stale: true, baselineSession: string|null, currentSession: string|null }
324
+ * | { intent: unknown, ownerBoundary: unknown, plannedFiles: unknown,
325
+ * session: string|null, frozenAt: unknown, branch: unknown,
326
+ * sessionStartRef: unknown }}
327
+ * `null` — no STATE.md, unreadable/malformed frontmatter, or no baseline
328
+ * has ever been frozen. `{ stale: true, ... }` — a baseline exists but
329
+ * belongs to a different session than the current `session` field.
330
+ * Otherwise the baseline object itself.
331
+ */
332
+ export function readBaseline(repoRoot) {
333
+ const result = readFrontmatterOrReason(repoRoot);
334
+ if (!result.ok) return null;
335
+ return classifyBaselineFromFrontmatter(result.fm);
336
+ }
337
+
338
+ /**
339
+ * Freeze (or refresh, when the stored baseline is stale) the scope baseline
340
+ * into STATE.md frontmatter. Writes exactly five flat keys:
341
+ * `scope-baseline-intent`, `scope-baseline-owner-boundary`,
342
+ * `scope-baseline-planned-files`, `scope-baseline-session`,
343
+ * `scope-baseline-frozen-at`. `branch` and `session-start-ref` are read
344
+ * from the existing frontmatter, never written by this function.
345
+ *
346
+ * Routes its write through `writeStateMd()` (scripts/lib/state-md.mjs) —
347
+ * see module docstring § Sync/async rule for why this is the ONLY async
348
+ * export and why it must NOT nest its own `withStateMdLock()` call.
349
+ * Never throws.
350
+ *
351
+ * @param {object} args
352
+ * @param {string|undefined} args.repoRoot
353
+ * @param {string} args.intent
354
+ * @param {string} args.ownerBoundary
355
+ * @param {string[]} args.plannedFiles — the RAW array of planned file paths
356
+ * (e.g. the union of declared agent file scopes for the session) —
357
+ * filtered internally via `countPlannedFiles()`, which calls the SAME
358
+ * `filterExcluded()` helper `computeDrift()`'s numerator uses (see the
359
+ * `DRIFT_EXCLUDE_PATTERNS` doc comment above and #894 review finding F1).
360
+ * MUST be an array. Issue #903 REMOVED the previously-accepted
361
+ * "already-counted plain number" back-compat shape: it was an unverified
362
+ * re-entry vector for exactly the F1 bug the filtering above exists to
363
+ * prevent (a caller could hand this function an unfiltered count with no
364
+ * `filterExcluded()` pass at all). Anything that is not an array is
365
+ * rejected UP FRONT — before STATE.md is read or the lock is taken —
366
+ * as `{ written: false, reason: 'invalid-planned-files' }`.
367
+ * @returns {Promise<{ written: boolean, reason?: 'invalid-planned-files'|'already-frozen'|'no-state-md'|'unreadable-state-md'|'lock-timeout'|'lock-fs-error'|'unexpected-error'|'size-ceiling'|'frontmatter-unsafe' }>}
368
+ */
369
+ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFiles } = {}) {
370
+ if (!Array.isArray(plannedFiles)) {
371
+ // #903 — the plain-number back-compat shape is REMOVED, not merely
372
+ // undocumented: it let a caller bypass `filterExcluded()` entirely by
373
+ // pre-counting (possibly unfiltered) and storing verbatim. Reject up
374
+ // front — no STATE.md read, no lock ever taken.
375
+ return { written: false, reason: 'invalid-planned-files' };
376
+ }
377
+
378
+ let skipReason;
379
+
380
+ try {
381
+ const result = await writeStateMd(repoRoot, (before) => {
382
+ // `writeStateMd()` passes an empty string when STATE.md does not
383
+ // exist on disk (frontmatter-mutators.mjs:304-305) — that IS the
384
+ // "no-state-md" signal at this layer.
385
+ if (before === '') {
386
+ skipReason = 'no-state-md';
387
+ return null;
388
+ }
389
+
390
+ const parsed = parseStateMd(before);
391
+ if (parsed === null) {
392
+ skipReason = 'unreadable-state-md';
393
+ return null;
394
+ }
395
+
396
+ const fm = parsed.frontmatter;
397
+ const currentSession = normalizeSessionValue(fm.session);
398
+ const hasBaseline = Object.prototype.hasOwnProperty.call(fm, 'scope-baseline-session');
399
+
400
+ if (hasBaseline) {
401
+ const baselineSession = normalizeSessionValue(fm['scope-baseline-session']);
402
+ if (baselineSession === currentSession) {
403
+ // Same session already froze a baseline (or both are absent,
404
+ // which counts as a match per rule 2 above) — reject, no mutation.
405
+ skipReason = 'already-frozen';
406
+ return null;
407
+ }
408
+ // Different session — the stored baseline is stale. Fall through
409
+ // and overwrite all five keys below.
410
+ }
411
+
412
+ // Resolve the denominator (#894 review finding F1, tightened by
413
+ // #903): `plannedFiles` is GUARANTEED to be an array here — the outer
414
+ // `writeBaseline()` already rejected anything else (see the
415
+ // `Array.isArray()` guard above) before this transformer ever ran.
416
+ // Filter it via `countPlannedFiles()` — the SAME `filterExcluded()`
417
+ // primitive `computeDrift()`'s numerator uses below, so both sides of
418
+ // the ratio are provably produced by one function.
419
+ const resolvedPlannedFiles = countPlannedFiles(plannedFiles);
420
+
421
+ fm['scope-baseline-intent'] = intent;
422
+ fm['scope-baseline-owner-boundary'] = ownerBoundary;
423
+ fm['scope-baseline-planned-files'] = resolvedPlannedFiles;
424
+ fm['scope-baseline-session'] = currentSession;
425
+ fm['scope-baseline-frozen-at'] = nowIso();
426
+
427
+ return serializeStateMd(parsed);
428
+ });
429
+
430
+ if (result.written) {
431
+ return { written: true };
432
+ }
433
+ // `writeStateMd()` itself refused the write (size-ceiling or
434
+ // frontmatter-unsafe breach) — surface that reason rather than falling
435
+ // back to the transformer's closure-captured reason, which was never
436
+ // set on this path.
437
+ if (result.reason === 'size-ceiling' || result.reason === 'frontmatter-unsafe') {
438
+ return { written: false, reason: result.reason };
439
+ }
440
+ return { written: false, reason: skipReason ?? 'unreadable-state-md' };
441
+ } catch (err) {
442
+ // `writeStateMd()` throws a `TypeError` only if the transformer returns
443
+ // a non-string/non-null/non-undefined value (never happens here), and
444
+ // the `withStateMdLock()` it takes internally THROWS a labelled Error on
445
+ // acquire failure BY DESIGN (state-md-lock.mjs:265-270 / :328), tagging
446
+ // `err.code` as `STATE_LOCK_TIMEOUT` or `STATE_LOCK_FS_ERROR`. This
447
+ // module's contract is "never throw" — map the escaping error's
448
+ // `err.code` HONESTLY (#894 review finding F4) instead of collapsing
449
+ // every failure into `'lock-timeout'`: a disk-full / permission failure
450
+ // on the lock directory must not misdirect the operator toward "retry,
451
+ // it's contention". (The invalid-`plannedFiles` case that used to throw
452
+ // from inside this transformer is gone as of #903 — it is rejected by
453
+ // the `Array.isArray()` guard before `writeStateMd()` is ever called,
454
+ // so it never reaches this catch block.)
455
+ if (err?.code === 'STATE_LOCK_TIMEOUT') {
456
+ return { written: false, reason: 'lock-timeout' };
457
+ }
458
+ if (err?.code === 'STATE_LOCK_FS_ERROR') {
459
+ return { written: false, reason: 'lock-fs-error' };
460
+ }
461
+ return { written: false, reason: 'unexpected-error' };
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Compute the S2 warn-only scope-drift ratio: how many files have actually
467
+ * changed since the session's frozen `session-start-ref`, filtered through
468
+ * `DRIFT_EXCLUDE_PATTERNS`, against the `plannedFiles` count frozen by
469
+ * `writeBaseline()`. Sync (`readFileSync` + `execFileSync('git', ...)`
470
+ * only). Never throws, never denies — this is a WARN-only tripwire; the
471
+ * CALLER decides whether/how to surface `breached`.
472
+ *
473
+ * Skip precedence (first match wins, so `reason` is deterministic):
474
+ * `no-state-md` → `unreadable-state-md` → `no-baseline` →
475
+ * `stale-baseline` → `unresolvable-ref`
476
+ *
477
+ * `session-start-ref` handling — MISSING vs UNRESOLVABLE are different:
478
+ * - Field absent from frontmatter → falls back to
479
+ * `git diff --name-only origin/main...HEAD` (the documented fallback,
480
+ * skills/session-end/plan-verification.md:37). This is NOT a skip —
481
+ * `refUsed` reports the fallback ref actually used.
482
+ * - Field present but the diff against it fails (rebase, force-push,
483
+ * deleted commit, …) → skip with `reason: 'unresolvable-ref'`.
484
+ *
485
+ * @param {object} args
486
+ * @param {string|undefined} args.repoRoot
487
+ * @param {number} [args.threshold] — breach threshold, `>=` counts as
488
+ * breached (default `2.0`).
489
+ * @returns {{ ok: true, skipped: true, reason: 'no-state-md'|'unreadable-state-md'|'no-baseline'|'stale-baseline'|'unresolvable-ref' }
490
+ * | { ok: true, skipped: false, filesRatio: number, plannedFiles: number,
491
+ * actualFiles: number, breached: boolean, threshold: number, refUsed: string }}
492
+ */
493
+ export function computeDrift({ repoRoot, threshold = 2.0 } = {}) {
494
+ const parsedFm = readFrontmatterOrReason(repoRoot);
495
+ if (!parsedFm.ok) {
496
+ return { ok: true, skipped: true, reason: parsedFm.reason };
497
+ }
498
+
499
+ const classified = classifyBaselineFromFrontmatter(parsedFm.fm);
500
+ if (classified === null) {
501
+ // Distinguishable from readFrontmatterOrReason's failures — STATE.md
502
+ // parses fine, but `scope-baseline-session` was never written.
503
+ return { ok: true, skipped: true, reason: 'no-baseline' };
504
+ }
505
+ if (classified.stale === true) {
506
+ // A fresh diff against an old denominator is noise, not a measurement
507
+ // — no filesRatio is computed on this path.
508
+ return { ok: true, skipped: true, reason: 'stale-baseline' };
509
+ }
510
+
511
+ const baseline = classified;
512
+ const cwd = repoRoot ?? process.cwd();
513
+ const rawRef = typeof baseline.sessionStartRef === 'string' && baseline.sessionStartRef.length > 0
514
+ ? baseline.sessionStartRef
515
+ : null;
516
+
517
+ const diffArgs = rawRef !== null
518
+ ? ['diff', '--name-only', `${rawRef}..HEAD`]
519
+ : ['diff', '--name-only', 'origin/main...HEAD'];
520
+ const refUsed = rawRef ?? 'origin/main...HEAD';
521
+
522
+ let stdout;
523
+ try {
524
+ stdout = execFileSync('git', diffArgs, { cwd, encoding: 'utf8' });
525
+ } catch {
526
+ // Ref present-but-unresolvable (rebase, force-push, deleted commit) OR
527
+ // the fallback diff itself failed (e.g. no origin/main) — both land in
528
+ // the same skip bucket; neither can produce a trustworthy numerator.
529
+ return { ok: true, skipped: true, reason: 'unresolvable-ref' };
530
+ }
531
+
532
+ const changedFiles = stdout
533
+ .split('\n')
534
+ .map((line) => line.trim())
535
+ .filter((line) => line.length > 0);
536
+
537
+ // Same `filterExcluded()` primitive `writeBaseline()`'s denominator
538
+ // (`countPlannedFiles()`) calls (#894 review finding F1) — both sides of
539
+ // the ratio are provably produced by one function.
540
+ const actualFiles = filterExcluded(changedFiles).length;
541
+
542
+ const plannedFilesRaw = baseline.plannedFiles;
543
+ const plannedFiles = typeof plannedFilesRaw === 'number' && Number.isFinite(plannedFilesRaw)
544
+ ? plannedFilesRaw
545
+ : 0;
546
+
547
+ // Mirrors the existing over_delivery_ratio div-by-zero guard in
548
+ // skills/wave-executor/wave-loop.md (`files_changed / max(planned_files_count, 1)`,
549
+ // in the per-wave metrics section). Deliberately un-line-pinned: that file is
550
+ // edited often enough that a pinned number goes stale, as this very comment did.
551
+ const filesRatio = round2(actualFiles / Math.max(plannedFiles, 1));
552
+ const breached = filesRatio >= threshold;
553
+
554
+ return {
555
+ ok: true,
556
+ skipped: false,
557
+ filesRatio,
558
+ plannedFiles,
559
+ actualFiles,
560
+ breached,
561
+ threshold,
562
+ refUsed,
563
+ };
564
+ }