session-orchestrator 3.17.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 (187) 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 +105 -412
  6. package/README.md +12 -9
  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/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tests-src-ratio.mjs — THE canonical tests:src LOC measurement for TV-003
4
+ * (`.claude/rules/test-value.md` § TV-003 Budget Corridor).
5
+ *
6
+ * ## Why this file exists
7
+ *
8
+ * TV-003 named a ceiling but no recipe, so the recipe lived in prose — and prose
9
+ * re-derives differently for every person who measures. On 2026-07-30 SIX numbers
10
+ * for the one metric were in simultaneous circulation (1.7142 / 1.7360 / 1.74 /
11
+ * 1.7779 / 1.8032 / 1.8084), differing only in which files each measurer chose to
12
+ * count. A threshold steered by six numbers is steered blind. This module is the
13
+ * single answer: the recipe is code, the number is reproducible at a SHA, and the
14
+ * rule text points here instead of describing a seventh calculation.
15
+ *
16
+ * ## The recipe (a PARTITION, not two independent globs)
17
+ *
18
+ * Every tracked code file lands in EXACTLY ONE bucket — that property is the
19
+ * whole design. Two independently-authored globs are how the six numbers arose:
20
+ * each hand-picked src list (`scripts+hooks` vs `+skills` vs `everything`) both
21
+ * dropped files silently and invited the next measurer to pick differently.
22
+ * Here `src` is defined by NEGATION, so a new top-level directory joins the
23
+ * denominator the moment it is committed, with no rule edit and no re-derivation.
24
+ *
25
+ * universe `git ls-files`, filtered to CODE_EXTENSIONS
26
+ * numerator tracked code under `tests/`
27
+ * denominator every OTHER tracked code file
28
+ *
29
+ * ## The four questions, answered in code rather than left open
30
+ *
31
+ * Do `skills/**.mjs` count as src? YES — shipped product code, and no
32
+ * hand-picked list decides it; negation does.
33
+ * Do `.md` files count? NO — neither side. Documentation volume
34
+ * is a different budget (instruction-budget);
35
+ * mixing them makes the ratio movable by
36
+ * writing prose, which catches no bug.
37
+ * Only git-tracked? YES — reproducible at a SHA, and it is
38
+ * what keeps node_modules/, coverage/ and
39
+ * untracked scratch out without an ignore list.
40
+ * Do `tests/fixtures/` count as test LOC? YES when they are code (`.mjs`/`.js`/
41
+ * `.cjs`). A code fixture is maintained
42
+ * code that exists only to serve the suite —
43
+ * that is test-corpus cost. Non-code
44
+ * fixtures (`.json`, `.jsonl`, `.md`) are
45
+ * excluded by the same extension filter
46
+ * that governs the denominator.
47
+ * Blank lines and comments? COUNTED — see countPhysicalLines().
48
+ *
49
+ * ## What this is NOT
50
+ *
51
+ * Not a ratchet, and deliberately so. TV-003 is a CORRIDOR: exceeding the ceiling
52
+ * switches the consolidation rule on, it does not break the build. `--check` is a
53
+ * machine-readable answer to "is the consolidation wave required?", which is the
54
+ * only job TV-003 gives the ratio ("the ratio is merely the trigger that switches
55
+ * it on"). Wiring it as a blocking CI gate would manufacture standing deletion
56
+ * pressure with no nameable target per file — the precise thing TV-001 and TV-002
57
+ * forbid, and the documented reason the predecessor ceiling of 1.20 was abandoned.
58
+ *
59
+ * Usage:
60
+ * tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]
61
+ *
62
+ * <repo-root> defaults to process.cwd()
63
+ * --json emit a single JSON object on stdout, nothing else
64
+ * --check exit 1 when the ratio exceeds the ceiling (consolidation
65
+ * wave required); exit 0 when inside the corridor
66
+ * --ceiling <n> override the TV-003 ceiling (default 1.60)
67
+ * --stdin take newline-separated paths from stdin instead of
68
+ * enumerating via `git ls-files` (test seam / staged-only mode)
69
+ *
70
+ * Exit codes:
71
+ * 0 — measurement completed (and, under --check, ratio is within the corridor)
72
+ * 1 — --check only: ratio exceeds the ceiling
73
+ * 2 — tool error (missing/unreadable root, bad argv)
74
+ */
75
+
76
+ import { readFileSync, existsSync, statSync } from 'node:fs';
77
+ import { join, isAbsolute, relative, resolve } from 'node:path';
78
+ import { execFileSync } from 'node:child_process';
79
+ import { fileURLToPath } from 'node:url';
80
+
81
+ import { writeStdoutLineSync } from './io.mjs';
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // The definition — the part that must never be re-derived by hand
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Extensions that count as CODE on both sides of the ratio. Anything else is in
89
+ * neither bucket: `.md` is documentation, `.json`/`.jsonl`/`.yml` are data.
90
+ * Applied symmetrically to numerator and denominator by construction — a file
91
+ * type can never inflate one side while being invisible to the other.
92
+ */
93
+ export const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
94
+
95
+ /** Path prefix that makes a tracked code file part of the TEST corpus. */
96
+ export const TEST_PREFIX = 'tests/';
97
+
98
+ /** The TV-003 ceiling. Exceeding it switches the consolidation rule on. */
99
+ export const DEFAULT_CEILING = 1.6;
100
+
101
+ /** Machine-readable schema tag for the --json envelope. */
102
+ export const SCHEMA = 'tests-src-ratio/1';
103
+
104
+ /**
105
+ * Which bucket a repo-relative path belongs to.
106
+ *
107
+ * `src` is defined by NEGATION — every tracked code file that is not under
108
+ * `tests/`. That is what makes the two buckets a partition rather than two
109
+ * globs that can overlap or leave a gap.
110
+ *
111
+ * @param {string} relPath repo-relative path, `/`-separated
112
+ * @returns {'test'|'src'|null} null = outside the metric entirely
113
+ */
114
+ export function classifyPath(relPath) {
115
+ const p = String(relPath).replace(/\\/g, '/').replace(/^\.\//, '');
116
+ if (!CODE_EXTENSIONS.some((ext) => p.endsWith(ext))) return null;
117
+ return p === TEST_PREFIX.slice(0, -1) || p.startsWith(TEST_PREFIX) ? 'test' : 'src';
118
+ }
119
+
120
+ /**
121
+ * Physical line count — blank lines and comment lines INCLUDED.
122
+ *
123
+ * Two deliberate choices:
124
+ *
125
+ * 1. No comment stripping. Stripping would need a real JS parser (a `//` inside
126
+ * a string, a regex literal, or a template literal is not a comment), so the
127
+ * stripper itself becomes a bug surface in the measuring instrument. It also
128
+ * inverts the incentive: comments sit in the DENOMINATOR too, so a stripper
129
+ * would reward deleting explanatory comments from src to move the ratio.
130
+ *
131
+ * 2. EOF-newline-insensitive. `wc -l` counts newline BYTES, so a file whose last
132
+ * line has no trailing newline is undercounted by one — an off-by-one that
133
+ * varies with an invisible byte. Here a trailing empty segment is dropped, so
134
+ * "10 lines" means ten lines with or without the final newline.
135
+ *
136
+ * @param {string} content
137
+ * @returns {number}
138
+ */
139
+ export function countPhysicalLines(content) {
140
+ if (content === '') return 0;
141
+ const parts = content.split('\n');
142
+ if (parts[parts.length - 1] === '') parts.pop();
143
+ return parts.length;
144
+ }
145
+
146
+ /**
147
+ * Measure the ratio over an explicit file list.
148
+ *
149
+ * Enumeration is injected rather than performed here, which is what lets the
150
+ * test drive a controlled fixture without a git repository.
151
+ *
152
+ * @param {object} opts
153
+ * @param {string[]} opts.files repo-relative paths
154
+ * @param {(relPath: string) => string|null} opts.readFile returns content, or null when unreadable
155
+ * @param {number} [opts.ceiling]
156
+ * @returns {{testFiles:number,testLoc:number,srcFiles:number,srcLoc:number,ratio:number|null,
157
+ * ceiling:number,withinCorridor:boolean,consolidationWaveRequired:boolean,skipped:number}}
158
+ */
159
+ export function measure({ files, readFile, ceiling = DEFAULT_CEILING }) {
160
+ let testFiles = 0;
161
+ let testLoc = 0;
162
+ let srcFiles = 0;
163
+ let srcLoc = 0;
164
+ let skipped = 0;
165
+
166
+ for (const rel of files) {
167
+ const bucket = classifyPath(rel);
168
+ if (bucket === null) continue;
169
+ const content = readFile(rel);
170
+ if (content === null || content === undefined) {
171
+ skipped++;
172
+ continue;
173
+ }
174
+ const lines = countPhysicalLines(content);
175
+ if (bucket === 'test') {
176
+ testFiles++;
177
+ testLoc += lines;
178
+ } else {
179
+ srcFiles++;
180
+ srcLoc += lines;
181
+ }
182
+ }
183
+
184
+ // A repo with no src code has an undefined ratio, not an infinite one. Saying
185
+ // `null` keeps the consumer from reading Infinity as a corridor breach.
186
+ const ratio = srcLoc === 0 ? null : Number((testLoc / srcLoc).toFixed(4));
187
+ const withinCorridor = ratio === null ? true : ratio <= ceiling;
188
+
189
+ return {
190
+ testFiles,
191
+ testLoc,
192
+ srcFiles,
193
+ srcLoc,
194
+ ratio,
195
+ ceiling,
196
+ withinCorridor,
197
+ consolidationWaveRequired: !withinCorridor,
198
+ skipped,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The self-describing definition block shipped inside every --json envelope, so
204
+ * a consumer never has to guess which recipe produced the number.
205
+ * @param {'git ls-files'|'stdin'} source
206
+ */
207
+ export function definitionOf(source) {
208
+ return {
209
+ source: source === 'stdin' ? 'stdin path list' : 'git ls-files (tracked files only)',
210
+ codeExtensions: [...CODE_EXTENSIONS],
211
+ numerator: `tracked code files under ${TEST_PREFIX}`,
212
+ denominator: `every OTHER tracked code file (src defined by negation, not by a directory list)`,
213
+ lineRule: 'physical lines; blank + comment lines counted; EOF-newline-insensitive',
214
+ excluded:
215
+ 'non-code extensions (.md, .json, .jsonl, .yml) on BOTH sides; untracked files (node_modules/, coverage/, scratch)',
216
+ };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Enumeration
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** @param {string} root */
224
+ function trackedFiles(root) {
225
+ const out = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
226
+ return out
227
+ .split('\n')
228
+ .filter(Boolean)
229
+ .map((rel) => rel.replace(/\\/g, '/'));
230
+ }
231
+
232
+ /** @param {string} root */
233
+ function stdinPaths(root) {
234
+ let raw;
235
+ try {
236
+ raw = readFileSync(0, 'utf8');
237
+ } catch {
238
+ return [];
239
+ }
240
+ return raw
241
+ .split('\n')
242
+ .map((l) => l.trim())
243
+ .filter(Boolean)
244
+ .map((p) => (isAbsolute(p) ? relative(root, p) : p).replace(/\\/g, '/'));
245
+ }
246
+
247
+ /** Short HEAD SHA, or null outside a git repo — the PSA-006 "measured WHEN" anchor. */
248
+ function headRef(root) {
249
+ try {
250
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
251
+ cwd: root,
252
+ encoding: 'utf8',
253
+ stdio: ['ignore', 'pipe', 'ignore'],
254
+ }).trim();
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Whether any TRACKED CODE file differs from the index/HEAD.
262
+ *
263
+ * Load-bearing, not cosmetic. Enumeration reads the git INDEX but line counts
264
+ * read the WORKING TREE, so on a dirty tree the pair (`ref`, `ratio`) is a claim
265
+ * nobody can reproduce at that SHA. Observed live while this module was written:
266
+ * a sibling agent grew one tracked src file 543 → 683 lines mid-measurement and
267
+ * the "same" ratio moved across three consecutive runs. `ref` alone would have
268
+ * stamped all three with the identical SHA.
269
+ *
270
+ * PSA-006 requires a measurement to carry WHEN it was taken; a SHA that does not
271
+ * reproduce the number fails that requirement while looking like it satisfies it.
272
+ *
273
+ * @returns {boolean|null} null when git cannot answer (not a repo)
274
+ */
275
+ function isDirty(root) {
276
+ try {
277
+ // --no-optional-locks is load-bearing, not tidiness: a plain `git status`
278
+ // opportunistically refreshes and therefore LOCKS .git/index, which races a
279
+ // parallel session's index write (PSA-007). This matters here specifically
280
+ // because .claude/rules/test-value.md now instructs agents to run this
281
+ // script, so it executes inside live sessions. Measured on git 2.50.1 with
282
+ // stale stat info: plain status rewrote .git/index, the flagged form did not.
283
+ // Same flag, same reason as hooks/post-bash-write-verify.mjs.
284
+ const out = execFileSync('git', ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], {
285
+ cwd: root,
286
+ encoding: 'utf8',
287
+ maxBuffer: 16 * 1024 * 1024,
288
+ stdio: ['ignore', 'pipe', 'ignore'],
289
+ });
290
+ return out
291
+ .split('\n')
292
+ .filter(Boolean)
293
+ .some((l) => classifyPath(l.slice(3).trim().split(' -> ').pop() ?? '') !== null);
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // CLI
301
+ // ---------------------------------------------------------------------------
302
+
303
+ const USAGE =
304
+ 'Usage: tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]';
305
+
306
+ /** @param {string[]} argv */
307
+ /**
308
+ * Session-start Phase 4 banner probe.
309
+ *
310
+ * WHY THIS EXISTS: without it this module had zero consumers. TV-003 defines the
311
+ * ceiling as the trigger for a consolidation wave — and the trigger fired into a
312
+ * void, since the only references were two rule files asking a human to type the
313
+ * command. "Not a blocking gate" was conflated with "not wired at all"; the
314
+ * counter-example shipped in the same commit range, where `checkInstructionBudget`
315
+ * is equally non-blocking and does get a Phase 4 banner. This closes that asymmetry
316
+ * WITHOUT making the ratio a build gate — the arguments against a bidirectional
317
+ * ratchet in `.claude/rules/test-value.md` § TV-003 stand unchanged.
318
+ *
319
+ * Contract matches the sibling probes (`checkInstructionBudget`, `checkCiStatus`,
320
+ * `checkMocStaleness`): returns `null` for "nothing to say", or a single
321
+ * `{ severity, message }` record. Never throws — any failure degrades to silence,
322
+ * because a measurement problem must not block a session start.
323
+ *
324
+ * @param {{ repoRoot?: string, ceiling?: number }} [opts]
325
+ * @returns {{ severity: 'warn', message: string, ratio: number, ceiling: number } | null}
326
+ */
327
+ export function checkTestsSrcRatio({ repoRoot, ceiling = DEFAULT_CEILING } = {}) {
328
+ try {
329
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
330
+ const root = resolve(repoRoot);
331
+ if (!existsSync(root)) return null;
332
+
333
+ const files = trackedFiles(root);
334
+ const readFile = (rel) => {
335
+ const abs = join(root, rel);
336
+ try {
337
+ if (!statSync(abs).isFile()) return null;
338
+ return readFileSync(abs, 'utf8');
339
+ } catch {
340
+ return null;
341
+ }
342
+ };
343
+
344
+ const result = measure({ files, readFile, ceiling });
345
+ if (result.ratio === null || result.withinCorridor) return null;
346
+
347
+ const dirty = isDirty(root);
348
+ return {
349
+ severity: 'warn',
350
+ ratio: result.ratio,
351
+ ceiling: result.ceiling,
352
+ message:
353
+ `⚠ tests:src ${result.ratio.toFixed(4)} > ceiling ${result.ceiling} — ` +
354
+ `TV-003 consolidation wave is ON: no new test lands without removing a redundant one ` +
355
+ `(${result.testLoc} test LOC / ${result.srcLoc} src LOC across ` +
356
+ `${result.testFiles} + ${result.srcFiles} files${dirty ? ', dirty tree' : ''}). ` +
357
+ `Detail: node scripts/lib/tests-src-ratio.mjs --json`,
358
+ };
359
+ } catch {
360
+ return null; // never block a session start on a measurement failure
361
+ }
362
+ }
363
+
364
+ export function parseArgs(argv) {
365
+ const KNOWN = new Set(['--json', '--check', '--stdin', '--ceiling', '--help']);
366
+ const positionals = [];
367
+ let json = false;
368
+ let check = false;
369
+ let stdin = false;
370
+ let help = false;
371
+ let ceiling = DEFAULT_CEILING;
372
+
373
+ for (let i = 0; i < argv.length; i++) {
374
+ const a = argv[i];
375
+ if (!a.startsWith('--')) {
376
+ positionals.push(a);
377
+ continue;
378
+ }
379
+ if (!KNOWN.has(a)) return { error: `Unknown flag: ${a}` };
380
+ if (a === '--json') json = true;
381
+ else if (a === '--check') check = true;
382
+ else if (a === '--stdin') stdin = true;
383
+ else if (a === '--help') help = true;
384
+ else if (a === '--ceiling') {
385
+ const v = Number(argv[++i]);
386
+ if (!Number.isFinite(v) || v <= 0) return { error: '--ceiling requires a positive number' };
387
+ ceiling = v;
388
+ }
389
+ }
390
+ if (positionals.length > 1) return { error: 'at most one positional <repo-root> is accepted' };
391
+ return { json, check, stdin, help, ceiling, root: positionals[0] };
392
+ }
393
+
394
+ function main() {
395
+ const args = parseArgs(process.argv.slice(2));
396
+ if (args.error) {
397
+ console.error(`Error: ${args.error}`);
398
+ console.error(USAGE);
399
+ process.exit(2);
400
+ }
401
+ if (args.help) {
402
+ console.log(USAGE);
403
+ console.log('');
404
+ console.log('The canonical tests:src LOC measurement for TV-003 (test-value.md).');
405
+ console.log(' numerator tracked code under tests/');
406
+ console.log(' denominator every other tracked code file (src by negation)');
407
+ console.log(` code exts ${CODE_EXTENSIONS.join(' ')} (.md / .json never counted)`);
408
+ console.log(' lines physical; blanks + comments counted; EOF-newline-insensitive');
409
+ console.log('');
410
+ console.log(' --json machine-readable envelope on stdout');
411
+ console.log(' --check exit 1 when the ratio exceeds the ceiling');
412
+ console.log(` --ceiling <n> override the TV-003 ceiling (default ${DEFAULT_CEILING})`);
413
+ console.log(' --stdin read newline-separated paths instead of git ls-files');
414
+ console.log('');
415
+ console.log('Exit: 0 ok / within corridor · 1 (--check) ceiling exceeded · 2 tool error');
416
+ process.exit(0);
417
+ }
418
+
419
+ const root = resolve(args.root ?? process.cwd());
420
+ if (!existsSync(root)) {
421
+ console.error(`Error: repo root does not exist: ${root}`);
422
+ process.exit(2);
423
+ }
424
+
425
+ let files;
426
+ try {
427
+ files = args.stdin ? stdinPaths(root) : trackedFiles(root);
428
+ } catch (err) {
429
+ console.error(`Error: could not enumerate files under ${root}: ${err?.message ?? err}`);
430
+ process.exit(2);
431
+ }
432
+
433
+ const readFile = (rel) => {
434
+ const abs = join(root, rel);
435
+ try {
436
+ if (!statSync(abs).isFile()) return null;
437
+ return readFileSync(abs, 'utf8');
438
+ } catch {
439
+ return null; // deleted/unreadable — counted as skipped, never as 0 lines
440
+ }
441
+ };
442
+
443
+ const result = measure({ files, readFile, ceiling: args.ceiling });
444
+ const dirty = args.stdin ? null : isDirty(root);
445
+ const envelope = {
446
+ schema: SCHEMA,
447
+ measuredAt: new Date().toISOString(),
448
+ root,
449
+ ref: headRef(root),
450
+ // `ref` is only a reproducible anchor when `dirty` is false — see isDirty().
451
+ dirty,
452
+ definition: definitionOf(args.stdin ? 'stdin' : 'git ls-files'),
453
+ ...result,
454
+ };
455
+
456
+ if (args.json) {
457
+ // writeStdoutLineSync, NOT console.log: stdout is async on a pipe on macOS,
458
+ // so anything past the ~64 KiB kernel buffer is discarded by process.exit().
459
+ // This envelope is small today, but --check exits explicitly below and the
460
+ // fail-open class is not worth re-litigating per payload size.
461
+ writeStdoutLineSync(JSON.stringify(envelope, null, 2));
462
+ } else {
463
+ const r = result.ratio === null ? 'n/a (no src code)' : result.ratio.toFixed(4);
464
+ const verdict = result.withinCorridor
465
+ ? 'within corridor'
466
+ : `ABOVE ceiling ${result.ceiling} — TV-003 consolidation wave required`;
467
+ const at = envelope.ref ? ` @ ${envelope.ref}${dirty ? '+dirty' : ''}` : '';
468
+ writeStdoutLineSync(
469
+ `tests:src = ${r} (${result.testLoc} test LOC / ${result.srcLoc} src LOC` +
470
+ `; ${result.testFiles} test + ${result.srcFiles} src files${at}) — ${verdict}` +
471
+ (dirty ? '\n NOTE: working tree is dirty — this number is NOT reproducible at that SHA' : ''),
472
+ );
473
+ }
474
+
475
+ process.exit(args.check && !result.withinCorridor ? 1 : 0);
476
+ }
477
+
478
+ const isMain =
479
+ typeof process !== 'undefined' &&
480
+ process.argv[1] !== null &&
481
+ process.argv[1] !== undefined &&
482
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
483
+
484
+ if (isMain) main();
@@ -451,6 +451,62 @@ if (existsSync(agentsDir)) {
451
451
  }
452
452
  }
453
453
 
454
+ // ============================================================================
455
+ // Check 10: PSA-007 git-write ban on repo-write agents (#724).
456
+ // Every agent that can write the repo (tools list contains an *Edit tool AND
457
+ // Write) MUST carry the subagent git-write prohibition in its BODY: a
458
+ // `PSA-007` reference plus the explicit `git stash` ban line. The git index
459
+ // and stash are shared working-copy resources — a dispatched agent that
460
+ // stages or stashes races its siblings and can silently discard their
461
+ // work-in-progress (.claude/rules/parallel-sessions.md § PSA-007).
462
+ // Structural invariant, so it lives in the gate rather than in a test
463
+ // (.claude/rules/test-value.md § TV-005). Bug it catches: a NEW repo-write
464
+ // agent ships with no ban — exactly the docs-writer gap #724 closed.
465
+ // Agents are discovered dynamically; no hardcoded roster to drift.
466
+ // ============================================================================
467
+ console.log('');
468
+ console.log('--- Check 10: PSA-007 git-write ban (repo-write agents) ---');
469
+
470
+ if (existsSync(agentsDir)) {
471
+ const psaMdFiles = readdirSync(agentsDir).filter(isAgentDefFile);
472
+ let repoWriteCount = 0;
473
+
474
+ for (const agentFile of psaMdFiles) {
475
+ const filePath = join(agentsDir, agentFile);
476
+ const content = readFileSync(filePath, 'utf8');
477
+ const fm = extractFrontmatter(content);
478
+ if (!fm) continue; // already caught by Check 6
479
+
480
+ const toolsArray = parseToolsValue(getField(fm, 'tools'));
481
+ // *Edit covers Edit / MultiEdit / NotebookEdit — all repo-mutating.
482
+ const canEdit = toolsArray.some((t) => /Edit$/.test(t));
483
+ if (!canEdit || !toolsArray.includes('Write')) continue;
484
+
485
+ repoWriteCount++;
486
+
487
+ // Body only: a `description:` that happens to mention PSA-007 must not
488
+ // satisfy the ban — the instruction has to reach the agent's rules.
489
+ const body = content.slice(content.indexOf(`\n---`, 3) + 4);
490
+ const missing = [];
491
+ if (!body.includes('PSA-007')) missing.push('a PSA-007 reference');
492
+ if (!body.includes('git stash')) missing.push('the `git stash` ban line');
493
+
494
+ if (missing.length > 0) {
495
+ fail(
496
+ `${agentFile}: repo-write agent (Edit+Write) is missing ${missing.join(' and ')} ` +
497
+ '— see .claude/rules/parallel-sessions.md § PSA-007 and agents/AGENTS.md § Authoring Convention',
498
+ );
499
+ } else {
500
+ pass(`${agentFile}: PSA-007 git-write ban present`);
501
+ }
502
+ }
503
+
504
+ if (repoWriteCount === 0) {
505
+ // No repo-write agents in this plugin root — nothing to enforce.
506
+ console.log(' (no repo-write agents found — check skipped)');
507
+ }
508
+ }
509
+
454
510
  console.log('');
455
511
  console.log(`Results: ${passed} passed, ${failed} failed`);
456
512