@event4u/agent-config 9.26.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 (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +57 -229
  3. package/dist/agent-src/commands/analyze/conformance.md +19 -0
  4. package/dist/agent-src/commands/roadmap/next.md +65 -3
  5. package/dist/agent-src/rules/active-remediation.md +2 -0
  6. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  7. package/dist/agent-src/rules/architecture.md +2 -0
  8. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  9. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  10. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  11. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  12. package/dist/agent-src/rules/brand-consistency.md +2 -0
  13. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  14. package/dist/agent-src/rules/broken-access-control.md +2 -0
  15. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  16. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  17. package/dist/agent-src/rules/code-provenance.md +2 -0
  18. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  19. package/dist/agent-src/rules/commit-conventions.md +2 -0
  20. package/dist/agent-src/rules/communication-through-line.md +2 -0
  21. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  22. package/dist/agent-src/rules/context-hygiene.md +10 -0
  23. package/dist/agent-src/rules/copilot-routing.md +2 -0
  24. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  25. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  26. package/dist/agent-src/rules/delegation-policy.md +2 -0
  27. package/dist/agent-src/rules/design-fidelity.md +2 -0
  28. package/dist/agent-src/rules/design-review-after-ui-write.md +4 -0
  29. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  30. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  31. package/dist/agent-src/rules/docker-commands.md +2 -0
  32. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  33. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  34. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  35. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  36. package/dist/agent-src/rules/downstream-changes.md +2 -0
  37. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  38. package/dist/agent-src/rules/evaluator-independence.md +13 -3
  39. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  40. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  41. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  42. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  43. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  44. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  45. package/dist/agent-src/rules/guidelines.md +2 -0
  46. package/dist/agent-src/rules/history-discipline.md +2 -0
  47. package/dist/agent-src/rules/icon-consistency.md +2 -0
  48. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  49. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  50. package/dist/agent-src/rules/invite-challenge.md +2 -0
  51. package/dist/agent-src/rules/laravel-routing.md +2 -0
  52. package/dist/agent-src/rules/laravel-translations.md +2 -0
  53. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  54. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  55. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  56. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  57. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  58. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  59. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  60. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  61. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  62. package/dist/agent-src/rules/model-recommendation.md +2 -0
  63. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  64. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  65. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  66. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  67. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  68. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  69. package/dist/agent-src/rules/output-discipline.md +2 -0
  70. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  71. package/dist/agent-src/rules/persona-governance.md +2 -0
  72. package/dist/agent-src/rules/php-coding.md +2 -0
  73. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  74. package/dist/agent-src/rules/preservation-guard.md +2 -0
  75. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  76. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  77. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  78. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  79. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  80. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  81. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  82. package/dist/agent-src/rules/runtime-safety.md +2 -0
  83. package/dist/agent-src/rules/scale-discipline.md +2 -0
  84. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  85. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  86. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  87. package/dist/agent-src/rules/session-canary.md +61 -19
  88. package/dist/agent-src/rules/settings-ask-protocol.md +4 -0
  89. package/dist/agent-src/rules/size-enforcement.md +2 -0
  90. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  91. package/dist/agent-src/rules/skill-quality.md +2 -0
  92. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  93. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  94. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  95. package/dist/agent-src/rules/source-of-truth.md +2 -0
  96. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  97. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  98. package/dist/agent-src/rules/symfony-routing.md +2 -0
  99. package/dist/agent-src/rules/think-before-action.md +2 -0
  100. package/dist/agent-src/rules/token-budget-discipline.md +2 -0
  101. package/dist/agent-src/rules/token-efficiency.md +2 -0
  102. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  103. package/dist/agent-src/rules/tool-safety.md +2 -0
  104. package/dist/agent-src/rules/ui-audit-gate.md +4 -0
  105. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  106. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  107. package/dist/agent-src/rules/user-interaction.md +2 -0
  108. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  109. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  110. package/dist/cli/registry.js +2 -0
  111. package/dist/cli/registry.js.map +1 -1
  112. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  113. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  114. package/dist/cli-delegate/cmd_conformance.js +1 -1
  115. package/dist/cli-delegate/cmd_doctor.js +1 -1
  116. package/dist/cli-delegate/cmd_export.js +1 -1
  117. package/dist/cli-delegate/cmd_prune.js +1 -1
  118. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  119. package/dist/cli-delegate/cmd_sync.js +1 -1
  120. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  121. package/dist/cli-delegate/cmd_validate.js +1 -1
  122. package/dist/discovery/deprecation-report.md +1 -1
  123. package/dist/discovery/discovery-manifest.json +109 -109
  124. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  125. package/dist/discovery/discovery-manifest.summary.md +1 -1
  126. package/dist/discovery/orphan-report.md +1 -1
  127. package/dist/discovery/packs.json +2 -2
  128. package/dist/discovery/trust-report.md +1 -1
  129. package/dist/discovery/workspaces.json +2 -2
  130. package/dist/hooks/dispatch.js +796 -289
  131. package/dist/install/install.mjs +1 -1
  132. package/dist/mcp/registry-manifest.json +2 -2
  133. package/dist/mcp/server.json +2 -2
  134. package/dist/mcp/server.mjs +571 -417
  135. package/dist/scripts/condense.js +33 -2
  136. package/dist/scripts/condense.js.map +1 -1
  137. package/dist/server/io/yamlIO.js +85 -0
  138. package/dist/server/io/yamlIO.js.map +1 -1
  139. package/dist/server/routes/settings.js +1 -12
  140. package/dist/server/routes/settings.js.map +1 -1
  141. package/dist/server/routes/wizard.js +52 -2
  142. package/dist/server/routes/wizard.js.map +1 -1
  143. package/dist/shared/settingsCarveOut.js +118 -0
  144. package/dist/shared/settingsCarveOut.js.map +1 -0
  145. package/dist/shared/settingsSurface.js +7 -1
  146. package/dist/shared/settingsSurface.js.map +1 -1
  147. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  148. package/package.json +1 -1
  149. package/src/config/evaluator-budgets.json +4 -4
  150. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  151. package/src/scripts/_dispatch.bash +18 -0
  152. package/src/scripts/_lib/git_common_dir.ts +128 -0
  153. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  154. package/src/scripts/_lib/session_register.ts +361 -0
  155. package/src/scripts/check_enforcement_coverage.ts +192 -7
  156. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  157. package/src/scripts/condense.ts +36 -2
  158. package/src/scripts/conformance_scan.ts +35 -0
  159. package/src/scripts/generate_settings_reference.ts +232 -0
  160. package/src/scripts/hook_manifest.yaml +68 -27
  161. package/src/scripts/hooks/block_no_verify.ts +56 -1
  162. package/src/scripts/hooks/block_unauthorized_git.ts +67 -1
  163. package/src/scripts/hooks/concern_registry.ts +2 -0
  164. package/src/scripts/language_mirror_hook.ts +56 -6
  165. package/src/scripts/orchestration_backfill.ts +354 -0
  166. package/src/scripts/release.ts +142 -36
  167. package/src/scripts/schemas/rule.schema.json +5 -0
  168. package/src/scripts/session_canary_hook.ts +35 -3
  169. package/src/scripts/session_register_hook.ts +288 -0
  170. package/src/scripts/sessions_cli.ts +140 -0
  171. package/src/scripts/validate_frontmatter.ts +68 -0
  172. package/src/server/io/yamlIO.ts +87 -0
  173. package/src/shared/settingsCarveOut.ts +143 -0
  174. 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
+ }
@@ -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
  /**
@@ -154,6 +154,11 @@
154
154
  },
155
155
  "description": "What mechanically enforces this rule. `validator:<script>` and `test:<path>` must resolve on disk AND be wired into a taskfile, workflow, or the hook manifest — a script that exists but runs nowhere resolves to unwired, not covered. `hook:<name>` must be registered in hook_manifest.yaml with fail_closed: true; a hook that never blocks is `observer:`, not `validator:`. `none` is legal and counted — an honest gap beats a false claim. Resolved, not trusted, by check_enforcement_coverage.ts."
156
156
  },
157
+ "obligation_frequency": {
158
+ "type": "string",
159
+ "enum": ["per-edit", "per-turn", "per-task", "per-session", "per-event", "per-commit", "per-file-write", "none"],
160
+ "description": "How often this rule's obligation comes due — the question `enforced_by` does not answer. `enforced_by` asks *does something carry this*; a carrier can be wired, reachable and firing and still fire in the wrong slot (session-canary: a per-task obligation carried by a session_start hook, measured dropped on ~13 of 15 task starts while counting as enforced). Joined against the carrier's per-slot-and-platform firing frequency by check_enforcement_coverage.ts. The value set is a FOREST, not a chain — three roots that never cover one another: lifecycle (`per-turn` > `per-task` > `per-session`), repository-write (`per-commit`, `per-file-write`), and external-event (`per-event`). `per-edit` is a fourth, incomparable-to-`per-turn` case: pre/post_tool_use fire per tool call, so a conversational turn with no tool call fires them zero times. `none` = no recurring obligation (a pointer or reference rule). Required on every non-kernel rule by validate_frontmatter.ts; the nine kernel rules are exempt because block_kernel_rule_writes.ts denies agent writes to them, and that exemption is derived from _lib/kernel_rules.ts so it closes itself the moment a rule leaves the kernel."
161
+ },
157
162
  "trust": {
158
163
  "type": "object",
159
164
  "additionalProperties": false,
@@ -184,6 +184,29 @@ export function sanitize_name(raw: string | null): string {
184
184
  return name.slice(0, 64);
185
185
  }
186
186
 
187
+ /**
188
+ * The per-turn reminder — one line, deliberately not the full contract.
189
+ *
190
+ * The obligation is per TASK; the injection was per SESSION. No host has a
191
+ * per-task slot (Cline maps TaskStart/TaskResume onto `session_start`; Claude
192
+ * Code has no task event), so the reachable covering period is per-turn, which
193
+ * is a strict superset. The measured consequence of leaving it at session scope:
194
+ * a 30-session audit found the opening canary dropped on ~13 of 15 task starts.
195
+ *
196
+ * Re-injecting the ~800-char contract every turn would buy that coverage at
197
+ * roughly 40× the tokens over a long session, so the full block stays at
198
+ * `session_start` and this line carries the beat. Over-firing a greeting is a
199
+ * visible, cheap failure; under-firing is the silent one being fixed.
200
+ */
201
+ export function build_canary_reminder(name: string): string {
202
+ return (
203
+ `<session-canary-beat>Canary active for "${name}": if this turn STARTS A NEW TASK,` +
204
+ ` open by addressing them by name. Same task as the last turn → do not re-greet.` +
205
+ ` A reply that lands work still ends with ONE summary (PR URL last).` +
206
+ `</session-canary-beat>`
207
+ );
208
+ }
209
+
187
210
  export function build_canary_block(name: string): string {
188
211
  return [
189
212
  `<session-canary settings-key="personal.canary_name">`,
@@ -219,9 +242,15 @@ export function main(): number {
219
242
  }
220
243
  const env: JsonObject = _isObject(envelope) ? envelope : {};
221
244
 
222
- // Only session_start carries context-injection semantics; other events no-op.
245
+ // Two injection points, two payloads. `session_start` carries the full
246
+ // contract once; `user_prompt_submit` carries the one-line beat, because the
247
+ // obligation is per task and session scope cannot reach it. Every other event
248
+ // no-ops — notably `stop`, where re-injecting would fire after the reply the
249
+ // reminder was meant to shape.
223
250
  const event = env["event"];
224
- if (typeof event === "string" && event !== "" && event !== "session_start") {
251
+ const slot =
252
+ typeof event === "string" && event !== "" ? event : "session_start";
253
+ if (slot !== "session_start" && slot !== "user_prompt_submit") {
225
254
  return EXIT_ALLOW;
226
255
  }
227
256
 
@@ -235,7 +264,10 @@ export function main(): number {
235
264
  `${JSON.stringify({
236
265
  decision: "allow",
237
266
  reason: `session-canary: active for "${name}" (${layer})`,
238
- context: build_canary_block(name),
267
+ context:
268
+ slot === "session_start"
269
+ ? build_canary_block(name)
270
+ : build_canary_reminder(name),
239
271
  })}\n`,
240
272
  );
241
273
  return EXIT_ALLOW;