@event4u/agent-config 9.25.0 → 9.27.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 (222) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +58 -143
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +21 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +256 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/active-remediation.md +2 -0
  10. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  11. package/dist/agent-src/rules/architecture.md +2 -0
  12. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  13. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  14. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  15. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  16. package/dist/agent-src/rules/brand-consistency.md +2 -0
  17. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  18. package/dist/agent-src/rules/broken-access-control.md +2 -0
  19. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  20. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  21. package/dist/agent-src/rules/code-provenance.md +2 -0
  22. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  23. package/dist/agent-src/rules/commit-conventions.md +2 -0
  24. package/dist/agent-src/rules/communication-through-line.md +2 -0
  25. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  26. package/dist/agent-src/rules/context-hygiene.md +10 -0
  27. package/dist/agent-src/rules/copilot-routing.md +2 -0
  28. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  29. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  30. package/dist/agent-src/rules/delegation-policy.md +5 -2
  31. package/dist/agent-src/rules/design-fidelity.md +19 -1
  32. package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
  33. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  34. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  35. package/dist/agent-src/rules/docker-commands.md +2 -0
  36. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  37. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  38. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  39. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  40. package/dist/agent-src/rules/downstream-changes.md +2 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +19 -3
  43. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  44. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  45. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  46. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  47. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  48. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  49. package/dist/agent-src/rules/guidelines.md +2 -0
  50. package/dist/agent-src/rules/history-discipline.md +2 -0
  51. package/dist/agent-src/rules/icon-consistency.md +2 -0
  52. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  53. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  54. package/dist/agent-src/rules/invite-challenge.md +2 -0
  55. package/dist/agent-src/rules/laravel-routing.md +2 -0
  56. package/dist/agent-src/rules/laravel-translations.md +2 -0
  57. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  58. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  59. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  60. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  61. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  62. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  63. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  64. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  65. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  66. package/dist/agent-src/rules/model-recommendation.md +2 -0
  67. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  68. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  69. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  70. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  71. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  72. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  73. package/dist/agent-src/rules/output-discipline.md +2 -0
  74. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  75. package/dist/agent-src/rules/persona-governance.md +2 -0
  76. package/dist/agent-src/rules/php-coding.md +2 -0
  77. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  78. package/dist/agent-src/rules/preservation-guard.md +2 -0
  79. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  80. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  81. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  82. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  83. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  84. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  85. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  86. package/dist/agent-src/rules/runtime-safety.md +2 -0
  87. package/dist/agent-src/rules/scale-discipline.md +2 -0
  88. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  89. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  90. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  91. package/dist/agent-src/rules/session-canary.md +63 -19
  92. package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
  93. package/dist/agent-src/rules/size-enforcement.md +2 -0
  94. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  95. package/dist/agent-src/rules/skill-quality.md +2 -0
  96. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  97. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  98. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  99. package/dist/agent-src/rules/source-of-truth.md +2 -0
  100. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  101. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  102. package/dist/agent-src/rules/symfony-routing.md +2 -0
  103. package/dist/agent-src/rules/think-before-action.md +2 -0
  104. package/dist/agent-src/rules/token-budget-discipline.md +8 -3
  105. package/dist/agent-src/rules/token-efficiency.md +2 -0
  106. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  107. package/dist/agent-src/rules/tool-safety.md +2 -0
  108. package/dist/agent-src/rules/ui-audit-gate.md +7 -0
  109. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  110. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  111. package/dist/agent-src/rules/user-interaction.md +5 -2
  112. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  113. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  114. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  115. package/dist/cli/registry.js +2 -0
  116. package/dist/cli/registry.js.map +1 -1
  117. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  118. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  119. package/dist/cli-delegate/cmd_conformance.js +1 -1
  120. package/dist/cli-delegate/cmd_doctor.js +1 -1
  121. package/dist/cli-delegate/cmd_export.js +1 -1
  122. package/dist/cli-delegate/cmd_prune.js +1 -1
  123. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  124. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  125. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  126. package/dist/cli-delegate/cmd_sync.js +1 -1
  127. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  128. package/dist/cli-delegate/cmd_validate.js +1 -1
  129. package/dist/discovery/deprecation-report.md +1 -1
  130. package/dist/discovery/discovery-manifest.json +191 -120
  131. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  132. package/dist/discovery/discovery-manifest.summary.md +4 -4
  133. package/dist/discovery/orphan-report.md +1 -1
  134. package/dist/discovery/packs.json +11 -8
  135. package/dist/discovery/trust-report.md +3 -3
  136. package/dist/discovery/workspaces.json +9 -6
  137. package/dist/hooks/dispatch.js +1148 -349
  138. package/dist/install/install.mjs +1 -1
  139. package/dist/mcp/registry-manifest.json +3 -3
  140. package/dist/mcp/server.json +2 -2
  141. package/dist/mcp/server.mjs +571 -417
  142. package/dist/router.json +1 -1
  143. package/dist/scripts/condense.js +33 -2
  144. package/dist/scripts/condense.js.map +1 -1
  145. package/dist/server/io/yamlIO.js +85 -0
  146. package/dist/server/io/yamlIO.js.map +1 -1
  147. package/dist/server/nicknameResolver.js +116 -0
  148. package/dist/server/nicknameResolver.js.map +1 -0
  149. package/dist/server/routes/ping.js +29 -11
  150. package/dist/server/routes/ping.js.map +1 -1
  151. package/dist/server/routes/settings.js +1 -12
  152. package/dist/server/routes/settings.js.map +1 -1
  153. package/dist/server/routes/wizard.js +52 -2
  154. package/dist/server/routes/wizard.js.map +1 -1
  155. package/dist/shared/interactiveContext.js +73 -0
  156. package/dist/shared/interactiveContext.js.map +1 -0
  157. package/dist/shared/nicknamePrefill.js +59 -0
  158. package/dist/shared/nicknamePrefill.js.map +1 -0
  159. package/dist/shared/settingsAsks.js +128 -0
  160. package/dist/shared/settingsAsks.js.map +1 -0
  161. package/dist/shared/settingsCarveOut.js +118 -0
  162. package/dist/shared/settingsCarveOut.js.map +1 -0
  163. package/dist/shared/settingsConsent.js +93 -0
  164. package/dist/shared/settingsConsent.js.map +1 -0
  165. package/dist/shared/settingsSurface.js +7 -1
  166. package/dist/shared/settingsSurface.js.map +1 -1
  167. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  168. package/package.json +1 -1
  169. package/src/config/agents-paths.yml +15 -0
  170. package/src/config/evaluator-budgets.json +4 -4
  171. package/src/config/gate-coverage.yml +29 -1
  172. package/src/config/gate-violation-baselines.json +13 -7
  173. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  174. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  175. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  176. package/src/scripts/_dispatch.bash +18 -0
  177. package/src/scripts/_lib/git_common_dir.ts +128 -0
  178. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  179. package/src/scripts/_lib/session_register.ts +361 -0
  180. package/src/scripts/ai-video/stitch.sh +27 -9
  181. package/src/scripts/before_complete_hook.ts +23 -4
  182. package/src/scripts/bench_ab_v2_run.ts +272 -16
  183. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  184. package/src/scripts/check_completion_review.ts +80 -3
  185. package/src/scripts/check_corpus_staleness.ts +692 -0
  186. package/src/scripts/check_enforcement_coverage.ts +192 -7
  187. package/src/scripts/check_gate_coverage.ts +67 -0
  188. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  189. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  190. package/src/scripts/condense.ts +36 -2
  191. package/src/scripts/conformance_scan.ts +82 -17
  192. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  193. package/src/scripts/generate_settings_reference.ts +232 -0
  194. package/src/scripts/git_authorization_hook.ts +75 -10
  195. package/src/scripts/hook_manifest.yaml +87 -27
  196. package/src/scripts/hooks/block_no_verify.ts +59 -2
  197. package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
  198. package/src/scripts/hooks/concern_registry.ts +2 -0
  199. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  200. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  201. package/src/scripts/hooks/evidence_independence.ts +40 -6
  202. package/src/scripts/language_mirror_hook.ts +256 -17
  203. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  204. package/src/scripts/lint_agents_layout.ts +2 -0
  205. package/src/scripts/lint_hook_manifest.ts +29 -0
  206. package/src/scripts/memory_learn_hook.ts +109 -6
  207. package/src/scripts/orchestration_backfill.ts +354 -0
  208. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  209. package/src/scripts/release.ts +180 -37
  210. package/src/scripts/schemas/review-findings.schema.json +99 -0
  211. package/src/scripts/schemas/rule.schema.json +5 -0
  212. package/src/scripts/session_canary_hook.ts +35 -3
  213. package/src/scripts/session_register_hook.ts +288 -0
  214. package/src/scripts/sessions_cli.ts +140 -0
  215. package/src/scripts/validate_frontmatter.ts +68 -0
  216. package/src/server/io/yamlIO.ts +87 -0
  217. package/src/shared/interactiveContext.ts +98 -0
  218. package/src/shared/nicknamePrefill.ts +96 -0
  219. package/src/shared/settingsAsks.ts +176 -0
  220. package/src/shared/settingsCarveOut.ts +143 -0
  221. package/src/shared/settingsConsent.ts +136 -0
  222. package/src/shared/settingsSurface.ts +7 -1
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Backfill orchestration telemetry from the host transcript corpus.
4
+ *
5
+ * Why this exists (road-to-orchestrator-first-execution Phase 1). Two roadmaps
6
+ * are blocked on "≥20 orchestration lines in the current month", with the
7
+ * stated remedy "use the agent on real delegable work". Measured 2026-08-07:
8
+ * the agent was used on real delegable work **370 times** in this repo over one
9
+ * month, and `orchestration_record` — a model-carried emit step — captured
10
+ * exactly **one** of them. The blocker was never a usage problem; it is an
11
+ * instrumentation defect, and the data it was waiting for is already on disk.
12
+ *
13
+ * What this reads. The host writes one JSONL transcript per session under
14
+ * `~/.claude/projects/<slugified-project-path>/`. An `Agent` dispatch appears
15
+ * as a `tool_use` block; its result appears as a `toolUseResult` object.
16
+ *
17
+ * The coverage limit, which is the point rather than a footnote. Only
18
+ * SYNCHRONOUS dispatches carry cost: `totalTokens`, `totalDurationMs`,
19
+ * `usage`. An ASYNCHRONOUS dispatch returns a launch acknowledgement
20
+ * (`isAsync: true`, `status: "async_launched"`) and its completion never
21
+ * writes cost back into the parent transcript. So the metric-bearing
22
+ * population is a strict subset, and this script reports both counts rather
23
+ * than the flattering one. Emitting 370 lines of which 331 carry a null cost
24
+ * would inflate n against a gate that counts lines.
25
+ *
26
+ * What this deliberately does NOT do. It does not compute a `token_delta`
27
+ * against an in-session baseline. That counterfactual is not on disk and is
28
+ * not measured by anything, so synthesising one here and stamping it
29
+ * `measured` is the exact laundering `orchestration-observed-dispatch-cost`
30
+ * criterion (2) forbids. The orchestrated side is measured; the baseline is a
31
+ * downstream decision that must name its own method.
32
+ *
33
+ * Read-only. Never mutates a transcript.
34
+ */
35
+
36
+ import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs';
37
+ import { join, basename } from 'node:path';
38
+ import { homedir } from 'node:os';
39
+
40
+ /** One dispatch-family. `unclassified` is a real answer, never a guess. */
41
+ export type DispatchFamily =
42
+ | 'read-only-fanout'
43
+ | 'ordered-steps'
44
+ | 'competitive'
45
+ | 'verdict-judge'
46
+ | 'unclassified';
47
+
48
+ /**
49
+ * One backfilled dispatch.
50
+ *
51
+ * PII/leak-exclusion by construction: the host's free-text `description` and
52
+ * `prompt` are read to CLASSIFY and are then dropped — no field here can carry
53
+ * free-form content. This is not fastidiousness: the first real run emitted a
54
+ * historical task description carrying a denylisted external-source name and
55
+ * tripped `check_no_external_sources`. A type that cannot hold arbitrary text
56
+ * has no scrubber to fail, which is the same principle
57
+ * `artifact-engagement-recording` states for telemetry events. Never widen
58
+ * this with a `description` / `notes` / `payload` field.
59
+ */
60
+ export interface BackfilledDispatch {
61
+ session_id: string;
62
+ tool_use_id: string;
63
+ timestamp: string | null;
64
+ subagent_type: string | null;
65
+ family: DispatchFamily;
66
+ family_signal: string;
67
+ resolved_model: string | null;
68
+ /** Measured orchestrated-side cost. `null` on an async dispatch. */
69
+ total_tokens: number | null;
70
+ wall_clock_ms: number | null;
71
+ tool_use_count: number | null;
72
+ /** `measured` only when the host recorded the cost; never inferred. */
73
+ cost_provenance: 'measured' | 'absent';
74
+ async_launch: boolean;
75
+ }
76
+
77
+ /**
78
+ * Slugify a project path the way the host names its transcript directory:
79
+ * every character outside `[A-Za-z0-9]` becomes `-`.
80
+ */
81
+ export function transcriptDirFor(projectPath: string, home = homedir()): string {
82
+ return join(home, '.claude', 'projects', projectPath.replace(/[^A-Za-z0-9]/g, '-'));
83
+ }
84
+
85
+ /**
86
+ * Classify a dispatch into a family from OBSERVABLE fields only.
87
+ *
88
+ * The council's round-2 finding was that eight dispatch templates were being
89
+ * treated as one hypothesis. Family is therefore carried per line. Where no
90
+ * enumerated signal matches, the answer is `unclassified` — the same
91
+ * discipline `classifyTask` applies when no delegable signal matches. A
92
+ * classifier that always produces a family would make the per-family split
93
+ * meaningless.
94
+ */
95
+ export function classifyFamily(
96
+ subagentType: string | null,
97
+ description: string | null,
98
+ prompt: string | null,
99
+ ): { family: DispatchFamily; signal: string } {
100
+ const hay = `${description ?? ''}\n${prompt ?? ''}`.toLowerCase();
101
+
102
+ // Verdict/judge first — it is the narrowest and can otherwise be swallowed
103
+ // by the read-only signal, since most verdict passes are also read-only.
104
+ if (subagentType === 'production-validator') {
105
+ return { family: 'verdict-judge', signal: 'subagent_type=production-validator' };
106
+ }
107
+ if (/\b(verify|verdict|judge|adjudicat|audit this|review this|acceptance criterion)\b/.test(hay)) {
108
+ return { family: 'verdict-judge', signal: 'verdict-shaped verb in description/prompt' };
109
+ }
110
+
111
+ if (subagentType === 'Explore') {
112
+ return { family: 'read-only-fanout', signal: 'subagent_type=Explore' };
113
+ }
114
+ if (/\b(read-only|do not edit|never edit|investigation only|map |inventory|locate)\b/.test(hay)) {
115
+ return { family: 'read-only-fanout', signal: 'read-only declaration in prompt' };
116
+ }
117
+
118
+ if (/\b(step \d|then |after (the )?previous|in order|sequentially|depends on step)\b/.test(hay)) {
119
+ return { family: 'ordered-steps', signal: 'ordering language in prompt' };
120
+ }
121
+
122
+ if (/\b(variant|competing|alternative approach|candidate [ab]|independently propose)\b/.test(hay)) {
123
+ return { family: 'competitive', signal: 'competitive framing in prompt' };
124
+ }
125
+
126
+ return { family: 'unclassified', signal: 'no enumerated family signal matched' };
127
+ }
128
+
129
+ interface Extraction {
130
+ dispatches: BackfilledDispatch[];
131
+ sessions_scanned: number;
132
+ unparseable_lines: number;
133
+ }
134
+
135
+ /** Walk one project's transcript corpus and pair each `Agent` tool_use with its result. */
136
+ export function extract(dir: string): Extraction {
137
+ if (!existsSync(dir)) {
138
+ return { dispatches: [], sessions_scanned: 0, unparseable_lines: 0 };
139
+ }
140
+ const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
141
+ const dispatches: BackfilledDispatch[] = [];
142
+ let unparseable = 0;
143
+
144
+ for (const file of files) {
145
+ const sessionId = file.replace(/\.jsonl$/, '');
146
+ // tool_use_id → the dispatch we are still waiting on a result for.
147
+ const pending = new Map<string, Partial<BackfilledDispatch> & { prompt: string | null; description: string | null }>();
148
+ const emitted = new Map<string, BackfilledDispatch>();
149
+
150
+ for (const line of readFileSync(join(dir, file), 'utf8').split('\n')) {
151
+ if (line.trim() === '') continue;
152
+ let obj: Record<string, unknown>;
153
+ try {
154
+ obj = JSON.parse(line) as Record<string, unknown>;
155
+ } catch {
156
+ unparseable += 1;
157
+ continue;
158
+ }
159
+
160
+ const message = obj.message as Record<string, unknown> | undefined;
161
+ const content = message?.content;
162
+ if (Array.isArray(content)) {
163
+ for (const block of content) {
164
+ const b = block as Record<string, unknown>;
165
+ if (b.type !== 'tool_use' || b.name !== 'Agent') continue;
166
+ const input = (b.input ?? {}) as Record<string, unknown>;
167
+ const id = String(b.id ?? '');
168
+ if (id === '') continue;
169
+ pending.set(id, {
170
+ session_id: sessionId,
171
+ tool_use_id: id,
172
+ timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : null,
173
+ subagent_type: typeof input.subagent_type === 'string' ? input.subagent_type : null,
174
+ description: typeof input.description === 'string' ? input.description : null,
175
+ prompt: typeof input.prompt === 'string' ? input.prompt : null,
176
+ });
177
+ }
178
+ }
179
+
180
+ const result = obj.toolUseResult as Record<string, unknown> | undefined;
181
+ if (!result || typeof result !== 'object') continue;
182
+ // The result carries no tool_use_id of its own; the host pairs it via
183
+ // the enclosing user message's tool_result block.
184
+ let resultId: string | null = null;
185
+ if (Array.isArray(content)) {
186
+ for (const block of content) {
187
+ const b = block as Record<string, unknown>;
188
+ if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') {
189
+ resultId = b.tool_use_id;
190
+ }
191
+ }
192
+ }
193
+ if (resultId === null || !pending.has(resultId)) continue;
194
+ const head = pending.get(resultId)!;
195
+ pending.delete(resultId);
196
+
197
+ const totalTokens = typeof result.totalTokens === 'number' ? result.totalTokens : null;
198
+ const { family, signal } = classifyFamily(
199
+ head.subagent_type ?? null,
200
+ head.description ?? null,
201
+ head.prompt,
202
+ );
203
+
204
+ emitted.set(resultId, {
205
+ session_id: sessionId,
206
+ tool_use_id: resultId,
207
+ timestamp: head.timestamp ?? null,
208
+ subagent_type: head.subagent_type ?? null,
209
+ family,
210
+ family_signal: signal,
211
+ resolved_model: typeof result.resolvedModel === 'string' ? result.resolvedModel : null,
212
+ total_tokens: totalTokens,
213
+ wall_clock_ms: typeof result.totalDurationMs === 'number' ? result.totalDurationMs : null,
214
+ tool_use_count: typeof result.totalToolUseCount === 'number' ? result.totalToolUseCount : null,
215
+ cost_provenance: totalTokens === null ? 'absent' : 'measured',
216
+ async_launch: result.isAsync === true,
217
+ });
218
+ }
219
+
220
+ dispatches.push(...emitted.values());
221
+ // A dispatch whose result never landed in this transcript (session ended
222
+ // mid-flight) is reported as a gap rather than dropped silently.
223
+ for (const [id, head] of pending) {
224
+ const { family, signal } = classifyFamily(
225
+ head.subagent_type ?? null,
226
+ head.description ?? null,
227
+ head.prompt,
228
+ );
229
+ dispatches.push({
230
+ session_id: sessionId,
231
+ tool_use_id: id,
232
+ timestamp: head.timestamp ?? null,
233
+ subagent_type: head.subagent_type ?? null,
234
+ family,
235
+ family_signal: signal,
236
+ resolved_model: null,
237
+ total_tokens: null,
238
+ wall_clock_ms: null,
239
+ tool_use_count: null,
240
+ cost_provenance: 'absent',
241
+ async_launch: false,
242
+ });
243
+ }
244
+ }
245
+
246
+ return { dispatches, sessions_scanned: files.length, unparseable_lines: unparseable };
247
+ }
248
+
249
+ /**
250
+ * The shape that actually gets written to disk.
251
+ *
252
+ * `tool_use_id` is pairing plumbing: it exists to match a dispatch to its
253
+ * result during extraction and has no analytical role afterwards. It is
254
+ * dropped at the TYPE level rather than scrubbed at write time, because the
255
+ * host ids are high-entropy opaque tokens and `check_secret_leak` flags them
256
+ * as candidate credentials — correctly, in the sense that it cannot know they
257
+ * are not. Emitting a field only to allow-list it downstream is the worse
258
+ * trade: the artifact does not need it.
259
+ */
260
+ export type EmittedDispatch = Omit<BackfilledDispatch, 'tool_use_id'>;
261
+
262
+ /** Project an extracted dispatch onto the emitted shape. */
263
+ export function toEmitted(d: BackfilledDispatch): EmittedDispatch {
264
+ const { tool_use_id: _pairingKey, ...rest } = d;
265
+ return rest;
266
+ }
267
+
268
+ /** Per-family roll-up. Medians, never means — one 530k outlier should not carry a family. */
269
+ export function summarize(dispatches: BackfilledDispatch[]): Record<string, unknown> {
270
+ const byFamily = new Map<string, number[]>();
271
+ for (const d of dispatches) {
272
+ if (d.cost_provenance !== 'measured' || d.total_tokens === null) continue;
273
+ const bucket = byFamily.get(d.family) ?? [];
274
+ bucket.push(d.total_tokens);
275
+ byFamily.set(d.family, bucket);
276
+ }
277
+ const families: Record<string, unknown> = {};
278
+ for (const [family, tokens] of byFamily) {
279
+ tokens.sort((a, b) => a - b);
280
+ families[family] = {
281
+ n: tokens.length,
282
+ // criterion (1): n<5 is UNDERPOWERED and is never merged to reach n.
283
+ power: tokens.length < 5 ? 'UNDERPOWERED' : 'reportable',
284
+ median_tokens: tokens[Math.floor(tokens.length / 2)],
285
+ min_tokens: tokens[0],
286
+ max_tokens: tokens[tokens.length - 1],
287
+ sum_tokens: tokens.reduce((a, b) => a + b, 0),
288
+ };
289
+ }
290
+ const measured = dispatches.filter((d) => d.cost_provenance === 'measured').length;
291
+ return {
292
+ dispatches_total: dispatches.length,
293
+ dispatches_with_measured_cost: measured,
294
+ dispatches_without_cost: dispatches.length - measured,
295
+ async_launches: dispatches.filter((d) => d.async_launch).length,
296
+ cost_coverage_ratio: dispatches.length === 0 ? 0 : Number((measured / dispatches.length).toFixed(4)),
297
+ families,
298
+ models: dispatches
299
+ .filter((d) => d.cost_provenance === 'measured')
300
+ .reduce<Record<string, number>>((acc, d) => {
301
+ const key = d.resolved_model ?? 'unknown';
302
+ acc[key] = (acc[key] ?? 0) + 1;
303
+ return acc;
304
+ }, {}),
305
+ baseline_note:
306
+ 'No in-session counterfactual exists on disk. token_delta is NOT computed here; ' +
307
+ 'any downstream comparison must name its own baseline method and stamp it estimated.',
308
+ };
309
+ }
310
+
311
+ function main(argv: string[]): number {
312
+ const projectArg = argv.indexOf('--project');
313
+ const project =
314
+ projectArg >= 0 && argv[projectArg + 1] !== undefined ? argv[projectArg + 1]! : process.cwd();
315
+ const outArg = argv.indexOf('--out');
316
+ const dir = transcriptDirFor(project);
317
+
318
+ if (!existsSync(dir)) {
319
+ process.stderr.write(`orchestration_backfill: no transcript corpus at ${dir}\n`);
320
+ return 1;
321
+ }
322
+
323
+ const { dispatches, sessions_scanned, unparseable_lines } = extract(dir);
324
+ const summary = summarize(dispatches);
325
+ // Emit the corpus identity, never the absolute path: the transcript
326
+ // directory is rooted at the operator's home and a committed report
327
+ // carrying it leaks an identity-revealing local path for no analytical
328
+ // gain (the basename already identifies which corpus was read).
329
+ const report = {
330
+ schema_version: 1,
331
+ source_corpus: basename(dir),
332
+ sessions_scanned,
333
+ unparseable_lines,
334
+ ...summary,
335
+ };
336
+
337
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
338
+
339
+ if (outArg >= 0 && argv[outArg + 1] !== undefined) {
340
+ const lines = dispatches
341
+ .filter((d) => d.cost_provenance === 'measured')
342
+ .map((d) => JSON.stringify({ input_kind: 'orchestration', orchestration: toEmitted(d) }))
343
+ .join('\n');
344
+ writeFileSync(argv[outArg + 1]!, lines === '' ? '' : `${lines}\n`, 'utf8');
345
+ process.stderr.write(
346
+ `orchestration_backfill: wrote ${summary.dispatches_with_measured_cost} measured line(s) to ${argv[outArg + 1]}\n`,
347
+ );
348
+ }
349
+ return 0;
350
+ }
351
+
352
+ if (process.argv[1] !== undefined && process.argv[1].includes('orchestration_backfill')) {
353
+ process.exit(main(process.argv.slice(2)));
354
+ }
@@ -66,6 +66,20 @@ function _isObject(v: unknown): v is JsonObject {
66
66
  }
67
67
 
68
68
  /** Return [tool_name, command_text] from a tool-event payload. */
69
+ /**
70
+ * Unwrap the dispatcher envelope.
71
+ *
72
+ * The dispatcher hands every concern `{event, platform, payload: <native>}`,
73
+ * but this hook read `tool_name` / `tool_response` off the TOP level. Fed a raw
74
+ * payload it worked; fed the real envelope it found nothing and returned ALLOW.
75
+ * Measured 2026-08-06: standalone 624 bytes of output, through the built
76
+ * dispatcher 0 bytes — the reminder had never fired in production.
77
+ */
78
+ function _unwrap(envelope: JsonObject): JsonObject {
79
+ const inner = envelope["payload"];
80
+ return _isObject(inner) ? inner : envelope;
81
+ }
82
+
69
83
  function _extractCommand(payload: JsonObject): [string | null, string | null] {
70
84
  const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
71
85
  const tool = typeof toolRaw === "string" ? toolRaw : null;
@@ -124,12 +138,13 @@ export function main(): number {
124
138
  return EXIT_ALLOW;
125
139
  }
126
140
 
127
- const [tool, command] = _extractCommand(envelope);
141
+ const inner = _unwrap(envelope);
142
+ const [tool, command] = _extractCommand(inner);
128
143
  if (!_isPrCreate(tool, command)) {
129
144
  return EXIT_ALLOW;
130
145
  }
131
146
 
132
- const match = _PR_URL.exec(_toolOutput(envelope));
147
+ const match = _PR_URL.exec(_toolOutput(inner));
133
148
  if (match === null) {
134
149
  return EXIT_ALLOW; // create attempted but no URL produced (failed / dry-run) — do not nag
135
150
  }
@@ -700,13 +700,84 @@ export function _failed_checks_report(names: readonly string[]): string {
700
700
  return lines.join('\n');
701
701
  }
702
702
 
703
+ /** Rounds of re-watching before an absent required check is called absent. */
704
+ const _NO_CHECKS_ROUNDS = 10;
705
+ /** Delay between those rounds — 10 × 60 s ≈ 10 minutes of tolerance. */
706
+ const _NO_CHECKS_DELAY_MS = 60_000;
707
+
703
708
  /**
704
- * Watch PR checks and tolerate the 'no checks' case.
709
+ * Required status-check contexts for a branch, parsed from
710
+ * `gh api repos/{owner}/{repo}/rules/branches/{branch}`.
711
+ *
712
+ * That endpoint is the one that answers the question here: this repo protects
713
+ * `main` with a RULESET, and the classic `/branches/main/protection` API
714
+ * answers "Branch not protected" for it — so a check built on the classic
715
+ * endpoint would conclude "no required checks" and wave everything through.
716
+ *
717
+ * Any parse or shape surprise yields [] — the caller then behaves exactly as
718
+ * it did before this function existed.
719
+ */
720
+ export function _required_contexts_from_rules(json: string): string[] {
721
+ let parsed: unknown;
722
+ try {
723
+ parsed = JSON.parse(json);
724
+ } catch {
725
+ return [];
726
+ }
727
+ if (!Array.isArray(parsed)) return [];
728
+ const out: string[] = [];
729
+ for (const rule of parsed) {
730
+ const r = rule as { type?: unknown; parameters?: { required_status_checks?: unknown } };
731
+ if (r.type !== 'required_status_checks') continue;
732
+ const checks = r.parameters?.required_status_checks;
733
+ if (!Array.isArray(checks)) continue;
734
+ for (const c of checks) {
735
+ const ctx = (c as { context?: unknown }).context;
736
+ if (typeof ctx === 'string' && ctx !== '') out.push(ctx);
737
+ }
738
+ }
739
+ return out;
740
+ }
741
+
742
+ export type NoChecksAction = 'retry' | 'accept' | 'die';
743
+
744
+ /**
745
+ * What to do when `gh pr checks` reports no checks at all.
746
+ *
747
+ * The old code had one answer — accept — for two situations it could not tell
748
+ * apart: a repo that configures no required checks (nothing will ever arrive,
749
+ * accepting is correct) and a repo whose checks have simply not been
750
+ * dispatched yet (accepting merges, tags and publishes unvalidated).
751
+ *
752
+ * The branch's own protection rules separate them. Required contexts declared
753
+ * ⇒ something IS expected, so absence means not-yet, and the answer is to wait
754
+ * and finally to refuse. None declared ⇒ the pre-existing tolerance stands.
755
+ */
756
+ export function _no_checks_action(
757
+ required: readonly string[],
758
+ round: number,
759
+ maxRounds: number = _NO_CHECKS_ROUNDS,
760
+ ): NoChecksAction {
761
+ if (required.length === 0) return 'accept';
762
+ return round < maxRounds ? 'retry' : 'die';
763
+ }
764
+
765
+ /**
766
+ * Watch PR checks and tolerate the 'no checks' case ONLY where it is tolerable.
705
767
  *
706
768
  * `gh pr checks --watch` exits 1 both on real failures and when no checks are
707
- * reported at all (no workflow triggered, no required checks configured in
708
- * branch protection). The latter must not block the release — we warn and
709
- * continue. Real failures still die.
769
+ * reported at all. Real failures still die.
770
+ *
771
+ * Measured 2026-08-06/07: during a critical GitHub Actions incident
772
+ * (15:22–02:04 UTC, webhook triggers throttled from 20:34) `pull_request`
773
+ * events were delivered 15–30 minutes late. The five-second grace period below
774
+ * expires long before that, so the old unconditional accept would have tagged
775
+ * `main` and published to npm without a single check having run. It did not
776
+ * only because `main` happened to be independently green.
777
+ *
778
+ * So the tolerance is now conditional on the branch declaring no required
779
+ * checks. Where it declares some, absence is treated as latency: re-watch, and
780
+ * refuse rather than release blind.
710
781
  *
711
782
  * A short grace period gives GitHub time to register workflow runs on a
712
783
  * freshly-pushed branch.
@@ -721,41 +792,76 @@ function watch_pr_checks(branch: string): void {
721
792
  // busy-wait stand-in for time.sleep(5) without an event-loop yield.
722
793
  }
723
794
  }
724
- // The branch is passed explicitly — `gh` inferring the PR from HEAD
725
- // resolved to `main` mid-run once (2026-08-03, 9.16.0 resume) and the
726
- // whole release died on "no pull requests found for branch main".
727
- const proc = run(['gh', 'pr', 'checks', branch, '--watch'], {
728
- check: false,
729
- capture: true,
730
- });
731
- const output = ((proc.stdout || '') + (proc.stderr || '')).trim();
732
- const returncode = proc.returncode;
733
- if (returncode === 0) {
795
+ // Rounds exist only for the no-checks-yet case below; every other outcome
796
+ // returns or dies on the first pass, exactly as before.
797
+ for (let round = 0; ; round++) {
798
+ // The branch is passed explicitly — `gh` inferring the PR from HEAD
799
+ // resolved to `main` mid-run once (2026-08-03, 9.16.0 resume) and the
800
+ // whole release died on "no pull requests found for branch main".
801
+ const proc = run(['gh', 'pr', 'checks', branch, '--watch'], {
802
+ check: false,
803
+ capture: true,
804
+ });
805
+ const output = ((proc.stdout || '') + (proc.stderr || '')).trim();
806
+ const returncode = proc.returncode;
807
+ if (returncode === 0) {
808
+ if (output) {
809
+ process.stdout.write(output + '\n');
810
+ }
811
+ return;
812
+ }
813
+ if (output.toLowerCase().includes('no checks reported')) {
814
+ const rules = run(
815
+ ['gh', 'api', `repos/{owner}/{repo}/rules/branches/${MAIN_BRANCH}`],
816
+ { check: false, capture: true },
817
+ );
818
+ const required = _required_contexts_from_rules(rules.stdout ?? '');
819
+ const action = _no_checks_action(required, round, _NO_CHECKS_ROUNDS);
820
+
821
+ if (action === 'accept') {
822
+ process.stdout.write(`⚠️ ${output}\n`);
823
+ process.stdout.write(
824
+ ' Continuing without check validation — configure required ' +
825
+ 'checks in branch protection to enforce this gate.\n',
826
+ );
827
+ return;
828
+ }
829
+ if (action === 'retry') {
830
+ process.stdout.write(
831
+ `⏳ no checks reported yet, but ${MAIN_BRANCH} requires ` +
832
+ `${String(required.length)} (${required.join(', ')}) — ` +
833
+ `waiting (${String(round + 1)}/${String(_NO_CHECKS_ROUNDS)})\n`,
834
+ );
835
+ if (_exec_override === null) {
836
+ const until = Date.now() + _NO_CHECKS_DELAY_MS;
837
+ while (Date.now() < until) {
838
+ // blocking wait, same shape as the grace period above
839
+ }
840
+ }
841
+ continue;
842
+ }
843
+ die(
844
+ `no checks reported for ${branch} after ` +
845
+ `${String(_NO_CHECKS_ROUNDS)} rounds, but ${MAIN_BRANCH} requires ` +
846
+ `${required.join(', ')}. Refusing to merge, tag and publish ` +
847
+ 'unvalidated — GitHub may be delaying event delivery (check ' +
848
+ 'githubstatus.com). Re-run with `task release -- --resume --yes` ' +
849
+ 'once the checks appear.',
850
+ );
851
+ }
734
852
  if (output) {
735
- process.stdout.write(output + '\n');
853
+ process.stderr.write(output + '\n');
736
854
  }
737
- return;
738
- }
739
- if (output.toLowerCase().includes('no checks reported')) {
740
- process.stdout.write(`⚠️ ${output}\n`);
741
- process.stdout.write(
742
- ' Continuing without check validation — configure required ' +
743
- 'checks in branch protection to enforce this gate.\n',
744
- );
745
- return;
746
- }
747
- if (output) {
748
- process.stderr.write(output + '\n');
749
- }
750
- const summary = run(['gh', 'pr', 'checks', branch, '--json', 'name,bucket'], {
751
- check: false,
752
- capture: true,
753
- });
754
- const report = _failed_checks_report(_failed_check_names(summary.stdout ?? ''));
755
- if (report) {
756
- process.stderr.write(report + '\n');
855
+ const summary = run(['gh', 'pr', 'checks', branch, '--json', 'name,bucket'], {
856
+ check: false,
857
+ capture: true,
858
+ });
859
+ const report = _failed_checks_report(_failed_check_names(summary.stdout ?? ''));
860
+ if (report) {
861
+ process.stderr.write(report + '\n');
862
+ }
863
+ die(`PR checks failed (exit ${returncode})`);
757
864
  }
758
- die(`PR checks failed (exit ${returncode})`);
759
865
  }
760
866
 
761
867
  /**
@@ -767,11 +873,48 @@ function watch_pr_checks(branch: string): void {
767
873
  * 2026-08-03, 9.15.0). Same-branch commits are never ours to drop, so the
768
874
  * recovery is merge-and-retry, never force-push.
769
875
  */
876
+ /**
877
+ * True when git rejected the push because the remote ref moved ahead — the
878
+ * ONLY failure the merge-and-retry recovery below can repair.
879
+ *
880
+ * git's wording for this is stable across versions: the hint is `fetch first`
881
+ * / `Updates were rejected`, and the ref line carries `[rejected]` with
882
+ * `non-fast-forward` or `fetch first`.
883
+ */
884
+ export function _is_non_fast_forward(stderr: string, stdout: string): boolean {
885
+ const text = `${stderr}\n${stdout}`;
886
+ return (
887
+ /\[rejected\]/i.test(text) &&
888
+ /(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text)
889
+ );
890
+ }
891
+
770
892
  function push_release_branch(branch: string): void {
771
- const first = run(['git', 'push', '-u', REMOTE, branch], { check: false });
893
+ const first = run(['git', 'push', '-u', REMOTE, branch], { check: false, capture: true });
772
894
  if (first.returncode === 0) {
895
+ process.stdout.write(first.stdout);
896
+ process.stderr.write(first.stderr);
773
897
  return;
774
898
  }
899
+
900
+ // A push can fail for many reasons — a pre-push hook refusing the commit,
901
+ // no credentials, a protected branch. Only ONE of them is repaired by
902
+ // fetch+merge+retry, and the recovery's first step (`git fetch <branch>`)
903
+ // exits 128 for a branch that was never pushed. Taking that path
904
+ // unconditionally replaced every real error with
905
+ // `couldn't find remote ref <branch>` — measured 2026-08-06 on 9.26.0,
906
+ // where a pre-push gate rejection surfaced as a git-fetch crash and cost
907
+ // an hour of diagnosis pointed at the wrong layer.
908
+ if (!_is_non_fast_forward(first.stderr, first.stdout)) {
909
+ process.stdout.write(first.stdout);
910
+ process.stderr.write(first.stderr);
911
+ die(
912
+ `push of ${branch} failed (exit ${first.returncode}) and the remote ref did not move — ` +
913
+ 'this is not the fetch-and-retry case. The push output above is the real error ' +
914
+ '(a pre-push gate, credentials, or branch protection); fix that and re-run.',
915
+ );
916
+ }
917
+
775
918
  process.stdout.write(`↻ ${REMOTE}/${branch} moved — integrating and retrying push\n`);
776
919
  run(['git', 'fetch', REMOTE, branch]);
777
920
  run(['git', 'merge', '--no-edit', `${REMOTE}/${branch}`]);