session-orchestrator 3.17.0 → 3.20.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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();
|
|
@@ -18,25 +18,52 @@
|
|
|
18
18
|
import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
import { findProjectRoot } from '../common.mjs';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* Path FRAGMENT joined against a resolved repo root at write time — NOT a
|
|
25
|
+
* relative path constant. A relative constant resolves against process.cwd(),
|
|
26
|
+
* which is how ~8k test-emitted tmux events landed in the real ledger: the
|
|
27
|
+
* suite spawns scripts/tmux-layout.mjs with cwd = repo root, so every
|
|
28
|
+
* telemetry write went straight into production telemetry.
|
|
29
|
+
* Same shape as scripts/lib/session-close-backfill.mjs § EVENTS_REL.
|
|
30
|
+
*/
|
|
31
|
+
const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when this process is a vitest run, or a child spawned by one
|
|
35
|
+
* (vitest sets VITEST=true and the child inherits process.env).
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function isTestRunner() {
|
|
39
|
+
return Boolean(process.env.VITEST) || process.env.VITEST_WORKER_ID !== undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Emit a single tmux-layout event to <repoRoot>/.orchestrator/metrics/events.jsonl.
|
|
25
44
|
* Best-effort — never throws (telemetry must not block the layout itself).
|
|
26
45
|
*
|
|
46
|
+
* Under a test runner an emit WITHOUT an explicit `repoRoot` is dropped: a test
|
|
47
|
+
* process has no business appending to a real ledger, and telemetry is
|
|
48
|
+
* best-effort by contract, so dropping is the correct degradation. Tests that
|
|
49
|
+
* assert on the write pass `repoRoot` and get the full write path.
|
|
50
|
+
*
|
|
27
51
|
* @param {string} eventType - 'tmux-layout.invoked' | 'tmux-layout.degraded' | 'tmux-layout.completed'
|
|
28
52
|
* @param {object} [payload] - additional fields (layout, duration_ms, reason, etc.)
|
|
53
|
+
* @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
|
|
29
54
|
*/
|
|
30
|
-
export function emit(eventType, payload = {}) {
|
|
55
|
+
export function emit(eventType, payload = {}, { repoRoot } = {}) {
|
|
31
56
|
try {
|
|
32
|
-
|
|
57
|
+
if (!repoRoot && isTestRunner()) return;
|
|
58
|
+
const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
|
|
59
|
+
const dir = path.dirname(eventsPath);
|
|
33
60
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
61
|
const record = {
|
|
35
62
|
event: eventType,
|
|
36
63
|
timestamp: new Date().toISOString(),
|
|
37
64
|
...payload,
|
|
38
65
|
};
|
|
39
|
-
appendFileSync(
|
|
66
|
+
appendFileSync(eventsPath, JSON.stringify(record) + '\n');
|
|
40
67
|
} catch {
|
|
41
68
|
// Best-effort — swallow all errors. Telemetry must not block layout.
|
|
42
69
|
}
|
|
@@ -45,18 +72,24 @@ export function emit(eventType, payload = {}) {
|
|
|
45
72
|
/**
|
|
46
73
|
* Wrap a layout function with telemetry. Emits invoked → completed/degraded.
|
|
47
74
|
*
|
|
75
|
+
* The repoRoot is taken ONLY from this explicit option — never derived from the
|
|
76
|
+
* wrapped call's own `projectRoot` argument. Deriving it would hand the spawned
|
|
77
|
+
* CLI an explicit root inside the test suite and re-open the exact
|
|
78
|
+
* production-ledger contamination path the emit() guard closes.
|
|
79
|
+
*
|
|
48
80
|
* @param {string} layoutName - 'default' | 'debug'
|
|
49
81
|
* @param {Function} fn - async function returning { ok, oneliner, panes, degraded, attachCommand, error? }
|
|
82
|
+
* @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
|
|
50
83
|
* @returns {Function} wrapped function with same signature
|
|
51
84
|
* @throws {TypeError} synchronously when fn is not a function
|
|
52
85
|
*/
|
|
53
|
-
export function withTelemetry(layoutName, fn) {
|
|
86
|
+
export function withTelemetry(layoutName, fn, { repoRoot } = {}) {
|
|
54
87
|
if (typeof fn !== 'function') {
|
|
55
88
|
throw new TypeError(`withTelemetry: fn must be a function (got ${typeof fn})`);
|
|
56
89
|
}
|
|
57
90
|
return async function telemetryWrapped(...args) {
|
|
58
91
|
const startedAt = Date.now();
|
|
59
|
-
emit('tmux-layout.invoked', { layout: layoutName });
|
|
92
|
+
emit('tmux-layout.invoked', { layout: layoutName }, { repoRoot });
|
|
60
93
|
try {
|
|
61
94
|
const result = await fn(...args);
|
|
62
95
|
const durationMs = Date.now() - startedAt;
|
|
@@ -66,13 +99,13 @@ export function withTelemetry(layoutName, fn) {
|
|
|
66
99
|
duration_ms: durationMs,
|
|
67
100
|
panes: result.panes ?? null,
|
|
68
101
|
degraded: result.degraded === true,
|
|
69
|
-
});
|
|
102
|
+
}, { repoRoot });
|
|
70
103
|
} else {
|
|
71
104
|
emit('tmux-layout.degraded', {
|
|
72
105
|
layout: layoutName,
|
|
73
106
|
duration_ms: durationMs,
|
|
74
107
|
reason: result?.error ?? 'unknown',
|
|
75
|
-
});
|
|
108
|
+
}, { repoRoot });
|
|
76
109
|
}
|
|
77
110
|
return result;
|
|
78
111
|
} catch (err) {
|
|
@@ -81,7 +114,7 @@ export function withTelemetry(layoutName, fn) {
|
|
|
81
114
|
layout: layoutName,
|
|
82
115
|
duration_ms: durationMs,
|
|
83
116
|
reason: `exception: ${err?.message ?? String(err)}`,
|
|
84
|
-
});
|
|
117
|
+
}, { repoRoot });
|
|
85
118
|
throw err;
|
|
86
119
|
}
|
|
87
120
|
};
|
|
@@ -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
|
|