@event4u/agent-config 9.27.0 → 9.28.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 (138) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +12 -11
  3. package/CHANGELOG.md +99 -0
  4. package/README.md +1 -1
  5. package/dist/agent-src/commands/analyze/conformance.md +24 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
  7. package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
  8. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  9. package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
  10. package/dist/agent-src/rules/context-hygiene.md +3 -1
  11. package/dist/agent-src/rules/council-availability.md +93 -0
  12. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  13. package/dist/agent-src/rules/delegation-policy.md +29 -5
  14. package/dist/agent-src/rules/design-fidelity.md +3 -0
  15. package/dist/agent-src/rules/evaluator-independence.md +2 -1
  16. package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
  17. package/dist/agent-src/rules/finance-safety-floor.md +1 -1
  18. package/dist/agent-src/rules/git-history-discipline.md +1 -1
  19. package/dist/agent-src/rules/history-discipline.md +3 -1
  20. package/dist/agent-src/rules/legal-safety-floor.md +1 -1
  21. package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
  22. package/dist/agent-src/rules/onboarding-gate.md +3 -1
  23. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
  24. package/dist/agent-src/rules/role-mode-adherence.md +1 -1
  25. package/dist/agent-src/rules/scale-discipline.md +3 -1
  26. package/dist/agent-src/rules/self-repair-loop.md +103 -0
  27. package/dist/agent-src/rules/session-canary.md +2 -1
  28. package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
  29. package/dist/agent-src/rules/token-budget-discipline.md +3 -1
  30. package/dist/agent-src/rules/tool-safety.md +5 -1
  31. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  32. package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
  33. package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
  34. package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
  35. package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
  36. package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
  37. package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
  38. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  39. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  40. package/dist/agent-src/templates/AGENTS.md +6 -10
  41. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  42. package/dist/cli/registry.js +7 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
  45. package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
  46. package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
  47. package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
  48. package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
  49. package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
  50. package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
  51. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  52. package/dist/cli-delegate/cmd_brand_status.js +134 -0
  53. package/dist/cli-delegate/cmd_conformance.js +7 -7
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +4 -4
  56. package/dist/cli-delegate/cmd_explain.js +80 -330
  57. package/dist/cli-delegate/cmd_export.js +5 -5
  58. package/dist/cli-delegate/cmd_fleet.js +3 -3
  59. package/dist/cli-delegate/cmd_handoff.js +2 -2
  60. package/dist/cli-delegate/cmd_mcp_available.js +433 -0
  61. package/dist/cli-delegate/cmd_migrate.js +3 -3
  62. package/dist/cli-delegate/cmd_packs_active.js +153 -0
  63. package/dist/cli-delegate/cmd_preflight.js +1 -1
  64. package/dist/cli-delegate/cmd_prune.js +5 -5
  65. package/dist/cli-delegate/cmd_route_audit.js +8 -8
  66. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  67. package/dist/cli-delegate/cmd_settings_get.js +272 -0
  68. package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
  69. package/dist/cli-delegate/cmd_settings_set.js +7 -2967
  70. package/dist/cli-delegate/cmd_sync.js +5 -5
  71. package/dist/cli-delegate/cmd_uninstall.js +5 -5
  72. package/dist/cli-delegate/cmd_update.js +4 -4
  73. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  74. package/dist/cli-delegate/cmd_validate.js +5 -5
  75. package/dist/cli-delegate/cmd_versions.js +4 -4
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +100 -40
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -3
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +7 -5
  82. package/dist/discovery/trust-report.md +11 -11
  83. package/dist/discovery/workspaces.json +6 -4
  84. package/dist/hooks/dispatch.js +9004 -6132
  85. package/dist/install/install.mjs +697 -507
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +127 -20
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +80 -15
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/docs/contracts/settings-classes.md +29 -8
  93. package/docs/guidelines/agent-infra/memory-access.md +14 -3
  94. package/package.json +1 -1
  95. package/src/agent-src/templates/AGENTS.md +6 -10
  96. package/src/config/budgets.yml +29 -0
  97. package/src/config/evaluator-budgets.json +4 -4
  98. package/src/config/gate-coverage.yml +26 -0
  99. package/src/scripts/_cli/cmd_brand_status.ts +174 -0
  100. package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
  101. package/src/scripts/_cli/cmd_packs_active.ts +225 -0
  102. package/src/scripts/_cli/cmd_settings_get.ts +324 -0
  103. package/src/scripts/_dispatch.bash +57 -0
  104. package/src/scripts/_lib/agent_settings.ts +82 -16
  105. package/src/scripts/_lib/carrier_divergence.ts +135 -0
  106. package/src/scripts/_lib/host_capability.ts +84 -1
  107. package/src/scripts/_lib/prompt_shape.ts +88 -0
  108. package/src/scripts/_lib/review_skipped_record.ts +102 -0
  109. package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
  110. package/src/scripts/_lib/self_repair.ts +342 -0
  111. package/src/scripts/_lib/self_repair_store.ts +100 -0
  112. package/src/scripts/_lib/transcript_entry.ts +80 -0
  113. package/src/scripts/check_branch_freshness.ts +255 -0
  114. package/src/scripts/check_council_config_location.ts +125 -1
  115. package/src/scripts/check_standing_rule_delivery.ts +314 -0
  116. package/src/scripts/config/profiles.ts +13 -3
  117. package/src/scripts/conformance_scan.ts +272 -24
  118. package/src/scripts/council_availability_hook.ts +188 -0
  119. package/src/scripts/council_cli.ts +126 -7
  120. package/src/scripts/hook_manifest.yaml +107 -26
  121. package/src/scripts/hooks/block_no_verify.ts +39 -2
  122. package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
  123. package/src/scripts/hooks/concern_registry.ts +10 -0
  124. package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
  125. package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
  126. package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
  127. package/src/scripts/install.ts +151 -0
  128. package/src/scripts/language_mirror_hook.ts +30 -37
  129. package/src/scripts/measure_scope_dedup.ts +19 -13
  130. package/src/scripts/onboarding_gate_hook.ts +40 -1
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/preamble_byte_census.ts +9 -20
  133. package/src/scripts/report_carrier_divergence.ts +364 -0
  134. package/src/scripts/report_skill_activation.ts +257 -0
  135. package/src/scripts/report_skill_obligation_violations.ts +579 -0
  136. package/src/scripts/routing_doctor.ts +77 -6
  137. package/src/scripts/self_repair_cli.ts +240 -0
  138. package/src/scripts/self_repair_hook.ts +241 -0
@@ -0,0 +1,726 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * End-review nudge — `stop` concern
4
+ * (road-to-orchestrator-discipline-carriers Phase 5, F4-lite).
5
+ *
6
+ * Five measured session transcripts found ZERO neutral end-of-task reviews —
7
+ * every session self-attested "CI green, ready for review". This concern
8
+ * fires the advisory obligation at the exact moment a mutating session ends
9
+ * without one, and counts the skip deterministically (hook-carried, not
10
+ * model-carried) so a future blocking decision has a measured distribution to
11
+ * cite. That decision — its threshold AND the stop-slot delivery question
12
+ * underneath it — is open and maintainer-owned; it is recorded in
13
+ * `agents/settings/contexts/orchestrator-carriers-open-decisions.md` § 1.
14
+ * It used to be cited here as blocker `f4-full-blocking-decision`, which
15
+ * stopped resolving the moment its roadmap was archived.
16
+ *
17
+ * Fires ONLY when BOTH hold:
18
+ * (a) the session mutated tracked, non-doc files beyond
19
+ * `MUTATION_LINE_THRESHOLD` (default 50) changed lines, AND
20
+ * (b) no reviewer ran this session (no Agent/Task dispatch whose prompt is
21
+ * review/judge-shaped, and no review/judge-skill invocation observed
22
+ * in the transcript).
23
+ *
24
+ * ## Diff source — chosen, not assumed
25
+ *
26
+ * `git diff --numstat HEAD` at `workspace_root`: the working tree vs the
27
+ * last commit, TRACKED files only, PLUS every untracked non-doc file
28
+ * (`git ls-files --others --exclude-standard`, one `git diff --numstat
29
+ * --no-index /dev/null <file>` per file — see "Untracked files" below;
30
+ * fixes a review finding, F6, that a brand-new file was previously invisible
31
+ * to this count entirely). Chosen over a session-start SHA/timestamp
32
+ * baseline because no such baseline exists anywhere a `stop` concern can
33
+ * read it (recording one would be new session-wide state, out of this
34
+ * roadmap phase's one-concern scope). Two consequences, stated rather than
35
+ * hidden:
36
+ * - Uncommitted changes that PREDATE this session (a dirty working tree
37
+ * the user already had) are counted as if they were this session's
38
+ * mutation. Per `commit-policy`, an agent almost never commits without
39
+ * explicit permission, so "uncommitted since HEAD" is usually a good
40
+ * proxy for "mutated this session" in practice — but it is a proxy, not
41
+ * an exact measure.
42
+ * - A session that DID commit mid-turn (one of `commit-policy`'s four
43
+ * exceptions) moves HEAD, so mutations already committed are invisible
44
+ * to this diff. Declared, not silently assumed away.
45
+ * - No git repo, no HEAD commit yet, or `git` unavailable → the diff read
46
+ * fails closed to `0` lines (silent), never a crash.
47
+ *
48
+ * ### Untracked files (F6)
49
+ *
50
+ * `git diff --numstat HEAD` diffs the INDEX/HEAD against the working tree —
51
+ * it is blind to a file that was never `git add`-ed at all, so a session
52
+ * that authored a brand-new 300-line module was previously measured as a
53
+ * 0-line mutation. `untrackedNonDocFiles` lists the candidates
54
+ * (`git ls-files --others --exclude-standard`, filtered by the same
55
+ * `isDocPath` carve-out); `untrackedFileLineCount` sizes each one via
56
+ * `git diff --no-index /dev/null <file>` (the only way to diff a path git's
57
+ * index has never seen). Past `UNTRACKED_FILE_CAP` untracked non-doc files
58
+ * this hook refuses to spawn one subprocess per file — an unbounded loop is
59
+ * worse than an imprecise answer — and instead reports a line count
60
+ * GUARANTEED to be over `MUTATION_LINE_THRESHOLD`: "many new files this
61
+ * session" is itself sufficient signal to fire without the exact count.
62
+ *
63
+ * ## Reviewer detection — a heuristic over the transcript, honestly scoped
64
+ *
65
+ * `payload.transcript_path` (present on Claude Code / Cowork stop events,
66
+ * per `chat_history.ts`'s `_extract_cursor_text` / `_extract_gemini_text`
67
+ * sibling readers) is scanned for an assistant `tool_use` block that is
68
+ * either:
69
+ * - an Agent/Task dispatch whose description/subagent_type text looks
70
+ * review- or judge-shaped (matched against the broader
71
+ * `REVIEW_SHAPED_LABEL_RE` — a short, deliberately-named field, so a
72
+ * bare `review`/`judge` keyword IS itself the signal), or its free-form
73
+ * `prompt` text matches the NARROWER `REVIEW_SHAPED_PROMPT_RE` (F15,
74
+ * review: the broader pattern false-positived on ordinary prose like
75
+ * "update the review docs" when it leaked into prompt text — a bare
76
+ * `\breview\b` is too weak a signal once the field can hold ANY prose
77
+ * an author writes, so the prompt-text pattern requires a more specific
78
+ * phrase: "code review", "neutral review", "adversarial", "judge",
79
+ * "critique", "second opinion", "blind pass/review", "cross-model" —
80
+ * each names the SHAPE of a review rather than merely mentioning the
81
+ * word), or
82
+ * - a Skill invocation naming a review/judge skill
83
+ * (`review-changes`, `judge-*`, `adversarial-review`, `code-review`).
84
+ *
85
+ * What this does NOT observe, stated per `security-sensitive-stop` /
86
+ * `untrusted-input-defense`'s honesty convention rather than left implicit:
87
+ * - A review narrated in prose with no tool call is invisible.
88
+ * - No `transcript_path` on the envelope (a platform this hook is not
89
+ * wired to, or a stripped payload) means reviewer activity CANNOT be
90
+ * confirmed — this hook resolves that ambiguity toward firing the
91
+ * advisory (favours recall: a spurious one-line nudge is cheap, a
92
+ * silently missed no-review session is the defect this concern exists
93
+ * to catch).
94
+ *
95
+ * ### Transcript-path hardening (gate finding fd47df62)
96
+ *
97
+ * `payload.transcript_path` is HOST-SUPPLIED, untrusted input, and this
98
+ * concern would otherwise `fs.readFileSync` it unconditionally. Before any
99
+ * read, `isSafeTranscriptPath` requires ALL of: (a) the path ends in
100
+ * `.jsonl` — the one extension a real transcript ever carries; (b) the path
101
+ * RESOLVES (`fs.realpathSync`, following any symlink) to somewhere under
102
+ * `os.homedir()` — real transcripts live under `~/.claude/projects/**`
103
+ * (`cache_realization_report.ts`, `mine_session.ts`, `orchestration_backfill.ts`
104
+ * all hard-code that root), never under `workspace_root`; a `workspace_root`
105
+ * prefix check would reject EVERY legitimate transcript and make this
106
+ * concern fire on every qualifying Stop instead of only when a reviewer
107
+ * genuinely didn't run; (c) the resolved file's size
108
+ * (`fs.statSync`) is at or under `TRANSCRIPT_SCAN_MAX_BYTES` (50 MB) — past
109
+ * that the scan is skipped rather than reading an unbounded file into
110
+ * memory. ANY validation failure resolves the SAME way an unreadable
111
+ * transcript already resolved pre-hardening: the scan is skipped and
112
+ * `reviewerRan` stays `false` — i.e. it favours firing, exactly the recall
113
+ * bias stated above for the missing-`transcript_path` case, never a crash
114
+ * and never a silent "reviewer ran" false positive.
115
+ *
116
+ * A TOCTOU/symlink race between the `realpathSync` check and the later
117
+ * `readFileSync` call exists in principle, but opens no new channel: the
118
+ * once-per-session marker derived from this same path
119
+ * (`deriveSessionKey`) hashes the path STRING, never reads or emits file
120
+ * content, so the worst case a race could still produce is the same 1-bit
121
+ * fire/no-fire outcome this concern already exposes by design — never a
122
+ * content leak.
123
+ *
124
+ * ## Once-per-session fire gate (F2) — and the cost ordering that protects it (F12)
125
+ *
126
+ * Every `stop` event re-evaluates the SAME session's diff and transcript, so
127
+ * without a fire gate this concern injected the advisory (and appended a
128
+ * `review_skipped` telemetry line) on EVERY qualifying Stop in a long
129
+ * session — a "session mutated N lines without review" nudge is a
130
+ * session-scope fact, not a per-turn one, and repeating it past the first
131
+ * fire is exactly the per-turn-canary shape `delegation-nudge`'s own header
132
+ * warns against. A marker file under
133
+ * `<workspace_root>/agents/runtime/state/end-review-nudge/<key>.json`
134
+ * (gitignored — `agents/runtime/` is a blanket-ignored path) records that
135
+ * THIS session already fired; a later Stop in the same session sees the
136
+ * marker and returns silently, without re-running the transcript scan.
137
+ *
138
+ * `<key>` is a SHA-256 of `envelope.session_id` (present on every
139
+ * dispatcher-built envelope, `envelope.ts`), falling back to
140
+ * `payload.transcript_path` for a legacy/raw-payload invocation that
141
+ * carries no `session_id` at all — hashed, never the raw value, so the
142
+ * state filename never leaks a real transcript path (mirrors the
143
+ * PRIVACY-BY-CONSTRUCTION discipline `review_skipped_record.ts` already
144
+ * states for the telemetry line itself: the fire-gate marker carries a
145
+ * timestamp and nothing else that could identify a session by content).
146
+ *
147
+ * ORDERING (F12, review): the once-per-session check runs AFTER the cheap
148
+ * `git diff --numstat` mutation check but BEFORE the expensive transcript
149
+ * read + line-by-line JSON parse. A session that already fired stops at a
150
+ * single `fs.existsSync` call — it never re-reads a (potentially large)
151
+ * transcript file it already knows the answer for.
152
+ *
153
+ * DELIVERY PATH — fixed to mirror `language_mirror_hook.ts` (found while
154
+ * verifying this concern end-to-end; the original plain-text
155
+ * `process.stdout.write(buildAdvisoryLine(...))` at exit 0 below is
156
+ * superseded). Plain text on stdout at exit 0 never reached the model on ANY
157
+ * platform: `dispatch_hook._parse_concern_stdout` fails to `JSON.parse` it
158
+ * and returns `{_raw_stdout: …}`, which carries neither a `reason` nor an
159
+ * `additional_context` key, so the concern's message text is silently
160
+ * dropped before `host_semantics.emitFor` is even called — independent of
161
+ * severity or platform. This concern now reports
162
+ * `{decision:"warn", reason, additional_context}` at exit `EXIT_WARN` (2),
163
+ * the one shape `dispatch_hook.ts` forwards into
164
+ * `hookSpecificOutput.additionalContext` on the verified `claude` platform.
165
+ *
166
+ * WHY EXIT 2 NEVER BLOCKS (verified, not assumed). `host_semantics.emitFor`'s
167
+ * `severity === "warn"` branch returns `{exit: 0, stdout:
168
+ * claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
169
+ * consult `CLAUDE_BLOCK_CAPABLE_EVENTS` at all (that set only gates the
170
+ * `"block"` severity branch, which this concern never reports). So on
171
+ * `claude` the real process exit handed back to the host is always 0 for a
172
+ * warn verdict on `stop`, exactly as it is on `user_prompt_submit` for
173
+ * `language_mirror_hook.ts` — the shipped concern this pattern is copied
174
+ * from, which returns the identical exit code on effectively every prompt
175
+ * carrying language markers without ever blocking a turn in production.
176
+ *
177
+ * `team_review_gate_hook.ts` — the other existing `stop` concern — was
178
+ * checked as a candidate second precedent and found to diverge in a way that
179
+ * is NOT worth following: it writes a plain-text `notice` at exit 0
180
+ * (`src/scripts/team_review_gate_hook.ts`), which the same
181
+ * `_parse_concern_stdout` / `severity==="allow"` discard applies to — its
182
+ * own advisory notice is, by the same trace, never delivered to the model on
183
+ * `claude` either. That is a pre-existing gap in a different concern, out of
184
+ * this task's scope to fix; this concern follows the ONE pattern verified to
185
+ * actually deliver (`language_mirror_hook.ts`), not the one that shares this
186
+ * concern's `stop` slot but does not deliver.
187
+ *
188
+ * PLATFORM SCOPE — bound only on `claude` in the manifest.
189
+ * `host_semantics.VERIFIED_PLATFORMS` covers only `claude` (the "never
190
+ * blocks" proof above). `cowork` is DELIBERATELY NOT bound (F10, review) —
191
+ * `scripts/hooks/cowork-dispatcher.sh` discards the dispatcher's exit code
192
+ * and stdout unconditionally (`>/dev/null 2>&1 || true; exit 0`), and this
193
+ * concern's entire delivery depends on `additional_context` reaching the
194
+ * model via stdout at the verified exit-2 warn path — a delivery that
195
+ * trampoline makes structurally impossible. The payload shape was never the
196
+ * blocker here: that same trampoline's own header comment states Cowork's
197
+ * `Stop` event does carry `transcript_path`, matching what this concern
198
+ * reads — the exit/stdout discard is what makes binding it dead weight, not
199
+ * a missing field. Cursor / Cline / Windsurf / Gemini are deliberately NOT
200
+ * added: none was inspected for the same discard property, their `stop`
201
+ * payload shape for `transcript_path` is undocumented here, and extending an
202
+ * exit-2 warn onto an unverified propagation path is exactly the
203
+ * speculative mapping `host_semantics.ts` exists to avoid.
204
+ *
205
+ * CONTRACT: never blocks THE ACTUAL TURN. The DISPATCHER-INTERNAL exit is 2
206
+ * on a fire (never 1/BLOCK, never >=3), `fail_closed: false`; the HOST-FACING
207
+ * exit on `claude` is 0 either way, per the proof above. Doc-only
208
+ * diffs, a reviewer having run, or any unreadable/malformed input all
209
+ * resolve to silence (dispatcher-internal exit 0), not a crash.
210
+ */
211
+ import { spawnSync } from 'node:child_process';
212
+ import * as crypto from 'node:crypto';
213
+ import * as fs from 'node:fs';
214
+ import * as os from 'node:os';
215
+ import * as path from 'node:path';
216
+ import { fileURLToPath, pathToFileURL } from 'node:url';
217
+
218
+ import { buildReviewSkippedLine, type MutationMeasure } from '../_lib/review_skipped_record.js';
219
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
220
+ import { readHookStdin } from './hook_stdin.js';
221
+ import { atomic_write_json, is_replay_mode } from './state_io.js';
222
+
223
+ /** Default fire threshold — the council's high-risk shape (roadmap 5.1). */
224
+ export const MUTATION_LINE_THRESHOLD = 50;
225
+
226
+ /**
227
+ * Untracked non-doc files past which this hook refuses to spawn one
228
+ * `git diff --no-index` subprocess per file and instead reports the
229
+ * mutation as certainly over threshold (F6, review — see the file header's
230
+ * "Untracked files" section).
231
+ */
232
+ export const UNTRACKED_FILE_CAP = 20;
233
+
234
+ /**
235
+ * Transcript-scan size cap (gate finding fd47df62 — see the file header's
236
+ * "Transcript-path hardening" section). Past this many bytes,
237
+ * `isSafeTranscriptPath` refuses the path and the scan is skipped.
238
+ */
239
+ export const TRANSCRIPT_SCAN_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
240
+
241
+ /**
242
+ * Validate a `transcript_path` BEFORE any `fs.readFileSync` of it (gate
243
+ * finding fd47df62). All three checks must pass:
244
+ * (a) the path ends in `.jsonl`;
245
+ * (b) it RESOLVES (`fs.realpathSync`, following any symlink) to a
246
+ * location under `homeDir` (defaults to `os.homedir()` — injectable
247
+ * for tests; production callers never override it — real transcripts
248
+ * live under `~/.claude/projects/**`, never under `workspace_root`,
249
+ * see the file header for why the prefix check is home, not
250
+ * workspace);
251
+ * (c) its resolved size is at or under `maxBytes` (defaults to
252
+ * `TRANSCRIPT_SCAN_MAX_BYTES` — injectable for tests only).
253
+ *
254
+ * Never throws — a missing file, a broken symlink, or any `fs` error all
255
+ * resolve to `false` (refuse), matching this hook's fail-open-to-firing
256
+ * contract for every other unreadable-transcript case.
257
+ */
258
+ export function isSafeTranscriptPath(
259
+ rawPath: string,
260
+ opts: { homeDir?: string; maxBytes?: number } = {},
261
+ ): boolean {
262
+ if (!rawPath || !/\.jsonl$/i.test(rawPath)) return false;
263
+ const homeDir = opts.homeDir ?? os.homedir();
264
+ const maxBytes = opts.maxBytes ?? TRANSCRIPT_SCAN_MAX_BYTES;
265
+ try {
266
+ const realHome = fs.realpathSync(homeDir);
267
+ const realPath = fs.realpathSync(rawPath);
268
+ const relative = path.relative(realHome, realPath);
269
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
270
+ return false;
271
+ }
272
+ const { size } = fs.statSync(realPath);
273
+ return size <= maxBytes;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ // Severity is taken from the EXIT CODE, not from the `decision` field in the
280
+ // stdout payload — mirrors `language_mirror_hook.ts`. A warn verdict is
281
+ // reported at exit 2 so `host_semantics.emitFor` reduces it to severity
282
+ // `"warn"` and forwards `additional_context`; see the file header for why
283
+ // this exit code never actually blocks the turn on `claude`.
284
+ const EXIT_WARN = 2;
285
+
286
+ /** One `git diff --numstat` row: added/deleted line counts + the path. */
287
+ export interface NumstatRow {
288
+ added: number;
289
+ deleted: number;
290
+ path: string;
291
+ }
292
+
293
+ /**
294
+ * Parse `git diff --numstat` output. Binary files print `-\t-\tpath` — those
295
+ * rows count as 0 lines (there is no line-level diff to weigh).
296
+ */
297
+ export function parseNumstat(raw: string): NumstatRow[] {
298
+ const rows: NumstatRow[] = [];
299
+ for (const line of raw.split('\n')) {
300
+ const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
301
+ if (!m) continue;
302
+ const addedStr = m[1] as string;
303
+ const deletedStr = m[2] as string;
304
+ rows.push({
305
+ added: addedStr === '-' ? 0 : Number(addedStr),
306
+ deleted: deletedStr === '-' ? 0 : Number(deletedStr),
307
+ path: m[3] as string,
308
+ });
309
+ }
310
+ return rows;
311
+ }
312
+
313
+ const DOC_PATH_RE = /\.md$/i;
314
+
315
+ /** True for the "outside `*.md`/docs" carve-out the roadmap's threshold names. */
316
+ export function isDocPath(p: string): boolean {
317
+ return DOC_PATH_RE.test(p);
318
+ }
319
+
320
+ /** Sum of added+deleted lines across every NON-doc row. */
321
+ export function nonDocMutatedLines(rows: readonly NumstatRow[]): number {
322
+ return rows
323
+ .filter((r) => !isDocPath(r.path))
324
+ .reduce((sum, r) => sum + r.added + r.deleted, 0);
325
+ }
326
+
327
+ /**
328
+ * `git diff --numstat HEAD` at `cwd`. Fails closed to `[]` (never throws) —
329
+ * no repo, no HEAD, or `git` missing all read as "nothing to measure".
330
+ */
331
+ export function gitNumstatRows(cwd: string): NumstatRow[] {
332
+ try {
333
+ const r = spawnSync('git', ['diff', '--numstat', 'HEAD'], {
334
+ cwd,
335
+ encoding: 'utf8',
336
+ maxBuffer: 16 * 1024 * 1024,
337
+ });
338
+ if (r.status !== 0) return [];
339
+ return parseNumstat(r.stdout ?? '');
340
+ } catch {
341
+ return [];
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Untracked, non-doc files at `cwd` (F6, review). `git diff --numstat HEAD`
347
+ * only ever diffs paths git's index already knows about — a brand-new file
348
+ * this session created is invisible to it. Fails closed to `[]` (never
349
+ * throws), matching `gitNumstatRows`'s own contract.
350
+ */
351
+ export function untrackedNonDocFiles(cwd: string): string[] {
352
+ try {
353
+ const r = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
354
+ cwd,
355
+ encoding: 'utf8',
356
+ maxBuffer: 16 * 1024 * 1024,
357
+ });
358
+ if (r.status !== 0) return [];
359
+ return (r.stdout ?? '')
360
+ .split('\n')
361
+ .map((l) => l.trim())
362
+ .filter((l) => l.length > 0 && !isDocPath(l));
363
+ } catch {
364
+ return [];
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Added+deleted line count for ONE untracked file, via
370
+ * `git diff --numstat --no-index /dev/null <relPath>` — the only way to
371
+ * diff a path git's index has never seen. `--no-index` exits `1` whenever a
372
+ * diff exists (by design, unlike `gitNumstatRows`'s tracked-diff contract,
373
+ * where a nonzero status means failure) — only the parsed numstat rows
374
+ * matter here, never the exit code. Fails closed to `0`, never throws.
375
+ */
376
+ export function untrackedFileLineCount(cwd: string, relPath: string): number {
377
+ try {
378
+ const r = spawnSync('git', ['diff', '--numstat', '--no-index', '/dev/null', relPath], {
379
+ cwd,
380
+ encoding: 'utf8',
381
+ maxBuffer: 16 * 1024 * 1024,
382
+ });
383
+ return parseNumstat(r.stdout ?? '').reduce((sum, row) => sum + row.added + row.deleted, 0);
384
+ } catch {
385
+ return 0;
386
+ }
387
+ }
388
+
389
+ /** Result of {@link totalNonDocMutatedLinesWithMeasure}. */
390
+ export interface MutationMeasurement {
391
+ lines: number;
392
+ /** Whether `lines` is exact, or a capped, guaranteed-over-threshold
393
+ * approximation (`UNTRACKED_FILE_CAP` path) — see that function. */
394
+ measure: MutationMeasure;
395
+ }
396
+
397
+ /**
398
+ * Total non-doc mutated lines across BOTH tracked (`git diff --numstat
399
+ * HEAD`) and untracked (`git ls-files --others --exclude-standard`) files —
400
+ * see the file header's "Untracked files (F6)" section for why both sources
401
+ * are required. Past `UNTRACKED_FILE_CAP` untracked non-doc files this
402
+ * function refuses to spawn one `git diff --no-index` subprocess per file
403
+ * (an unbounded loop) and instead returns a value GUARANTEED to be over
404
+ * `MUTATION_LINE_THRESHOLD` — "many new files this session" is itself
405
+ * sufficient signal, no exact count needed. `measure` records WHICH of
406
+ * those two shapes the returned `lines` value is, so a consumer (the
407
+ * `review_skipped` telemetry line, via `main()`) never mixes exact and
408
+ * approximated counts in one unlabeled stream.
409
+ */
410
+ export function totalNonDocMutatedLinesWithMeasure(cwd: string): MutationMeasurement {
411
+ const trackedLines = nonDocMutatedLines(gitNumstatRows(cwd));
412
+ const untracked = untrackedNonDocFiles(cwd);
413
+ if (untracked.length > UNTRACKED_FILE_CAP) {
414
+ return { lines: MUTATION_LINE_THRESHOLD + 1 + trackedLines, measure: 'capped_approximation' };
415
+ }
416
+ const untrackedLines = untracked.reduce(
417
+ (sum, relPath) => sum + untrackedFileLineCount(cwd, relPath),
418
+ 0,
419
+ );
420
+ return { lines: trackedLines + untrackedLines, measure: 'exact' };
421
+ }
422
+
423
+ /**
424
+ * Backward-compatible, lines-only view of
425
+ * {@link totalNonDocMutatedLinesWithMeasure} — kept for callers (and tests)
426
+ * that only need the count, not which measure produced it.
427
+ */
428
+ export function totalNonDocMutatedLines(cwd: string): number {
429
+ return totalNonDocMutatedLinesWithMeasure(cwd).lines;
430
+ }
431
+
432
+ function isObject(v: unknown): v is JsonObject {
433
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
434
+ }
435
+
436
+ function str(v: JsonValue | undefined): string {
437
+ return typeof v === 'string' ? v : '';
438
+ }
439
+
440
+ // Agent/Task dispatch tool names this environment's subagent-spawn primitive
441
+ // is known under. Matched case-insensitively.
442
+ const DISPATCH_TOOL_RE = /^(agent|task)$/i;
443
+ // `description`/`subagent_type` are short, deliberately-named fields (an
444
+ // author names a subagent's TYPE or writes a one-line label for it) — a
445
+ // bare `review`/`judge`/etc keyword there IS itself the signal.
446
+ const REVIEW_SHAPED_LABEL_RE =
447
+ /\b(review|judge|adversarial|critique|second[- ]opinion|blind\s+(pass|review)|cross-model)\b/i;
448
+ // F15 (review): `prompt` is free-form prose an author writes for ANY
449
+ // purpose, so the same bare `\breview\b` false-positived on ordinary work
450
+ // ("update the review docs"). Narrower on purpose — each phrase names the
451
+ // SHAPE of a review rather than merely mentioning the word.
452
+ const REVIEW_SHAPED_PROMPT_RE =
453
+ /\b(code\s+review|neutral\s+review|adversarial|judge|critique|second[- ]opinion|blind\s+(pass|review)|cross-model)\b/i;
454
+ // Skill invocation naming a review/judge skill (hyphen- or colon-separated
455
+ // namespace forms both seen across this suite's skill/command catalogue).
456
+ const REVIEW_SKILL_NAME_RE =
457
+ /^(review[-:]changes|judge[-:][a-z0-9-]+|adversarial-review|code-review)$/i;
458
+
459
+ /**
460
+ * True when at least one assistant `tool_use` block in `transcriptText`
461
+ * looks like a neutral review/judge pass — an Agent/Task dispatch with a
462
+ * review-shaped `description`/`subagent_type` label or `prompt` text, or a
463
+ * Skill invocation naming a review/judge skill.
464
+ *
465
+ * Scans the WHOLE transcript (session scope), not just the latest turn —
466
+ * "no reviewer ran this session" is a session-wide claim.
467
+ */
468
+ export function scanTranscriptForReviewer(transcriptText: string): boolean {
469
+ for (const raw of transcriptText.split('\n')) {
470
+ const line = raw.trim();
471
+ if (!line) continue;
472
+ let obj: unknown;
473
+ try {
474
+ obj = JSON.parse(line);
475
+ } catch {
476
+ continue;
477
+ }
478
+ if (!isObject(obj) || obj['type'] !== 'assistant') continue;
479
+ const msg = obj['message'];
480
+ if (!isObject(msg)) continue;
481
+ const content = msg['content'];
482
+ if (!Array.isArray(content)) continue;
483
+ for (const blk of content) {
484
+ if (!isObject(blk) || blk['type'] !== 'tool_use') continue;
485
+ const name = str(blk['name'] as JsonValue | undefined);
486
+ const input = blk['input'];
487
+ const inputObj = isObject(input) ? input : {};
488
+ if (DISPATCH_TOOL_RE.test(name)) {
489
+ const label = [
490
+ str(inputObj['description'] as JsonValue | undefined),
491
+ str(inputObj['subagent_type'] as JsonValue | undefined),
492
+ ]
493
+ .filter(Boolean)
494
+ .join(' ');
495
+ if (REVIEW_SHAPED_LABEL_RE.test(label)) return true;
496
+ const prompt = str(inputObj['prompt'] as JsonValue | undefined);
497
+ if (REVIEW_SHAPED_PROMPT_RE.test(prompt)) return true;
498
+ continue;
499
+ }
500
+ if (/^skill$/i.test(name)) {
501
+ const skillName =
502
+ str(inputObj['skill'] as JsonValue | undefined) ||
503
+ str(inputObj['name'] as JsonValue | undefined) ||
504
+ str(inputObj['command'] as JsonValue | undefined);
505
+ if (REVIEW_SKILL_NAME_RE.test(skillName)) return true;
506
+ continue;
507
+ }
508
+ // A tool_use literally named after a review/judge skill (some
509
+ // hosts expose a skill invocation as its own tool name).
510
+ if (REVIEW_SKILL_NAME_RE.test(name)) return true;
511
+ }
512
+ }
513
+ return false;
514
+ }
515
+
516
+ /** The exactly-one advisory line this concern injects when it fires. */
517
+ export function buildAdvisoryLine(diffLines: number): string {
518
+ return (
519
+ `this session mutated ${diffLines} lines without a neutral review; ` +
520
+ `spawn a cross-model reviewer before claiming done (delegation-policy / verify-budget)`
521
+ );
522
+ }
523
+
524
+ /**
525
+ * Append ONE `review_skipped` line to the monthly audit log. Never throws —
526
+ * a telemetry write failure must not cost the user their turn.
527
+ *
528
+ * Honours `AGENT_CONFIG_REPLAY=1` the same way `orchestration_record_hook.ts`
529
+ * does for its own per-call JSONL append (F5, review) — a fixture-replay run
530
+ * must never write real telemetry, and this write had no such guard.
531
+ */
532
+ export function appendReviewSkippedTelemetry(
533
+ workspaceRoot: string,
534
+ diffLines: number,
535
+ mutationMeasure: MutationMeasure,
536
+ ): void {
537
+ if (is_replay_mode()) return;
538
+ const ts = new Date().toISOString();
539
+ const { line, errors } = buildReviewSkippedLine({
540
+ diff_lines: diffLines,
541
+ mutation_measure: mutationMeasure,
542
+ ts,
543
+ id: crypto.randomUUID(),
544
+ });
545
+ if (errors.length || line === null) return; // refused input — never write a broken line
546
+ try {
547
+ const dir = path.join(workspaceRoot, 'agents', 'runtime', 'state', 'audit');
548
+ fs.mkdirSync(dir, { recursive: true });
549
+ const file = path.join(dir, `${ts.slice(0, 7)}.jsonl`);
550
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
551
+ } catch {
552
+ // an unwritable audit dir must never break the Stop path
553
+ }
554
+ }
555
+
556
+ /** Directory holding the once-per-session fire-gate markers (F2, review). */
557
+ function sessionStateDir(workspaceRoot: string): string {
558
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'end-review-nudge');
559
+ }
560
+
561
+ function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
562
+ return path.join(sessionStateDir(workspaceRoot), `${sessionKey}.json`);
563
+ }
564
+
565
+ /**
566
+ * Derive a stable, filesystem-safe once-per-session key (F2, review).
567
+ * Prefers the dispatcher envelope's own `session_id` (present on every
568
+ * envelope the dispatcher itself builds — `envelope.ts`'s `unwrap`); falls
569
+ * back to `payload.transcript_path` for a legacy/raw-payload invocation
570
+ * that carries no `session_id` at all. HASHED, never stored raw, so the
571
+ * marker filename can never leak a real transcript path.
572
+ *
573
+ * FULL digest, not truncated (council finding, this task): a 64-bit
574
+ * truncation (16 hex chars) makes birthday collisions between unrelated
575
+ * sessions non-negligible at the population sizes this hook will
576
+ * accumulate over time — a collision silently merges two sessions' fire
577
+ * gates, undercounting `review_skipped` telemetry and biasing any future
578
+ * blocking-threshold calibration downward. The full 256-bit hex digest
579
+ * makes that collision probability negligible; the classification risk
580
+ * this key carries is a 1-bit fire/no-fire outcome (see the file header's
581
+ * "Transcript-path hardening" section), never file content, so there is no
582
+ * privacy cost to keeping the full digest.
583
+ */
584
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
585
+ const raw =
586
+ str(envelope['session_id'] as JsonValue | undefined) ||
587
+ str((payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined);
588
+ const material = raw || 'unknown-session';
589
+ return crypto.createHash('sha256').update(material).digest('hex');
590
+ }
591
+
592
+ /**
593
+ * True when THIS session already fired the nudge (F2, review). Fails
594
+ * closed to `false` on a read error — "not yet fired" is the safer
595
+ * default: worst case it repeats a nudge, it never silences a genuine one
596
+ * over a merely-unreadable state directory.
597
+ */
598
+ export function hasFiredThisSession(workspaceRoot: string, sessionKey: string): boolean {
599
+ try {
600
+ return fs.existsSync(sessionStateFile(workspaceRoot, sessionKey));
601
+ } catch {
602
+ return false;
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Record that THIS session has fired. `atomic_write_json` is already
608
+ * `AGENT_CONFIG_REPLAY`-aware (a no-op under replay), so a fixture-replay
609
+ * run leaves no marker behind either — consistent with the telemetry-write
610
+ * guard above.
611
+ */
612
+ function markFiredThisSession(workspaceRoot: string, sessionKey: string, ts: string): void {
613
+ try {
614
+ atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), { fired_at: ts });
615
+ } catch {
616
+ // a state-write failure must never block the Stop path
617
+ }
618
+ }
619
+
620
+ function _read_stdin(): string {
621
+ return readHookStdin();
622
+ }
623
+
624
+ export function main(): number {
625
+ const [envelope, payload] = unwrap(_read_stdin(), 'claude');
626
+
627
+ const event = String(envelope['event'] ?? '');
628
+ if (event !== '' && event !== 'stop') {
629
+ return 0;
630
+ }
631
+
632
+ const workspace_root =
633
+ String(envelope['workspace_root'] ?? '').trim() || process.cwd();
634
+
635
+ let diffLines: number;
636
+ let mutationMeasure: MutationMeasure;
637
+ try {
638
+ const measured = totalNonDocMutatedLinesWithMeasure(workspace_root);
639
+ diffLines = measured.lines;
640
+ mutationMeasure = measured.measure;
641
+ } catch {
642
+ return 0; // fail-open — never block the agent loop
643
+ }
644
+ if (diffLines <= MUTATION_LINE_THRESHOLD) {
645
+ return 0; // below threshold, or a doc-only diff — nothing to nudge
646
+ }
647
+
648
+ // F2/F12 (review): the once-per-session fire gate runs on the CHEAP
649
+ // git-diff result, BEFORE the expensive transcript read + line-by-line
650
+ // JSON parse below. A session that already fired stops here, at a
651
+ // single `fs.existsSync` call, without re-reading a (potentially
652
+ // large) transcript file it already knows the answer for.
653
+ const sessionKey = deriveSessionKey(envelope, payload);
654
+ if (hasFiredThisSession(workspace_root, sessionKey)) {
655
+ return 0;
656
+ }
657
+
658
+ const transcriptPath = str(
659
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
660
+ ).trim();
661
+ let reviewerRan = false;
662
+ // Gate finding fd47df62: validate BEFORE reading — see the file header's
663
+ // "Transcript-path hardening" section. A path that fails validation
664
+ // (wrong extension, resolves outside home, oversized) is treated exactly
665
+ // like an unreadable transcript below: the scan is skipped and
666
+ // `reviewerRan` stays `false`, resolving toward firing, not silence.
667
+ if (transcriptPath && isSafeTranscriptPath(transcriptPath)) {
668
+ try {
669
+ reviewerRan = scanTranscriptForReviewer(fs.readFileSync(transcriptPath, 'utf-8'));
670
+ } catch {
671
+ // unreadable transcript — cannot confirm a reviewer ran; see the
672
+ // header note on why that resolves toward firing, not silence.
673
+ reviewerRan = false;
674
+ }
675
+ }
676
+ if (reviewerRan) {
677
+ return 0;
678
+ }
679
+
680
+ // Only mark THIS session as fired once the nudge is actually about to
681
+ // be injected — a turn that stopped above (below threshold, already
682
+ // fired, or a reviewer ran) never claims the once-per-session slot.
683
+ markFiredThisSession(workspace_root, sessionKey, new Date().toISOString());
684
+
685
+ try {
686
+ appendReviewSkippedTelemetry(workspace_root, diffLines, mutationMeasure);
687
+ } catch {
688
+ // never let a telemetry failure block or fail the turn
689
+ }
690
+
691
+ process.stdout.write(
692
+ `${JSON.stringify({
693
+ decision: 'warn',
694
+ reason: `end-review-nudge: session mutated ${diffLines} non-doc lines without a neutral review`,
695
+ additional_context: buildAdvisoryLine(diffLines),
696
+ })}\n`,
697
+ );
698
+ return EXIT_WARN;
699
+ }
700
+
701
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
702
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
703
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
704
+ function _isCliEntry(): boolean {
705
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
706
+ return false;
707
+ }
708
+ if (process.argv[1] === undefined) {
709
+ return false;
710
+ }
711
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
712
+ if (import.meta.url === argvUrl) {
713
+ return true;
714
+ }
715
+ try {
716
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
717
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
718
+ return here === argv;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+
724
+ if (_isCliEntry()) {
725
+ process.exit(main());
726
+ }