@event4u/agent-config 9.29.0 → 9.31.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -28,6 +28,7 @@
28
28
  import { createHash } from 'node:crypto';
29
29
 
30
30
  import { redact_low_impact_entry } from '../ai_council/redact_low_impact_entry.js';
31
+ import { is_kernel_rule } from './kernel_rules.js';
31
32
 
32
33
  /** Where a record came from. */
33
34
  export type DefectSource = 'user-reported' | 'self-detected';
@@ -128,6 +129,199 @@ export function egressBlockedReason(record: DefectRecord, repoRoot?: string | nu
128
129
  return `privacy floor refused the evidence (${kinds}) — record stays local`;
129
130
  }
130
131
 
132
+ // ── may-not-modify deny-list ───────────────────────────────────────
133
+
134
+ /**
135
+ * Surfaces self-repair may never target with a PATCH. Detection and reporting
136
+ * stay untouched — for a record targeting one of these, egress degrades to
137
+ * report-only: an issue (or the local record) is still allowed, the
138
+ * pull-request path refuses with the named reason.
139
+ *
140
+ * The point is structural: the loop that fixes the agent's own defects must
141
+ * not be a channel through which the agent loosens the floors it is bound by.
142
+ * Same posture as `block_kernel_rule_writes` (tool-call-time) and the settings
143
+ * class-C fence (write-time), applied to the one outward vehicle this loop
144
+ * owns. Nothing auto-applies a patch today; this keeps that true for the
145
+ * denied surfaces even when the PR path is otherwise available.
146
+ */
147
+ export type DeniedSurface =
148
+ | 'kernel-rule'
149
+ | 'safety-floor-rule'
150
+ | 'settings-class-c'
151
+ | 'ci-enforcement'
152
+ | 'self-repair-policy';
153
+
154
+ export interface PatchDenial {
155
+ surface: DeniedSurface;
156
+ /** The token in the record that matched. */
157
+ match: string;
158
+ }
159
+
160
+ /**
161
+ * Characters that decorate a token but never appear inside a real path, rule
162
+ * id, or settings key: markdown emphasis / strikethrough, autolink angle
163
+ * brackets, table pipes. They are removed ANYWHERE in the token, not just at
164
+ * its edges, so `src/rules/**scope-control**.md` reduces like `**…**` does.
165
+ */
166
+ const DECORATION_RE = /[*~<>|]/g;
167
+
168
+ /**
169
+ * Edge punctuation a sentence leaves on a token — sentence-final `.`, a `:`
170
+ * label separator, list/reference brackets, and underscore emphasis (`_` is
171
+ * stripped at the EDGES only: it is load-bearing inside `self_repair.ts`,
172
+ * `hook_manifest.yaml`, and every snake_case settings key).
173
+ */
174
+ const EDGE_TRIM_RE = /^[.:[\]_]+|[.:[\]_]+$/g;
175
+
176
+ /**
177
+ * Word-ish / path-ish tokens of a record's text, punctuation-stripped.
178
+ *
179
+ * `suggested_surface` is free-text the agent writes, and this repo's prose is
180
+ * saturated with `**bold**` file paths — so a tokenizer that only splits on
181
+ * whitespace-and-quotes lets ordinary markdown emphasis smuggle a denied
182
+ * surface past the scan (`**src/rules/scope-control.md**` read as ALLOWED
183
+ * while the same path bare read as DENIED). Both the raw token and its
184
+ * un-decorated form are emitted, and the edge trim runs AFTER the decoration
185
+ * strip so an unwrapped `**.github/workflows/ci.yml**` cannot keep a leading
186
+ * dot in front of the anchored `github/workflows/` match.
187
+ */
188
+ function denyTokens(text: string): string[] {
189
+ const out: string[] = [];
190
+ for (const raw of text.split(/[\s,;()!?"'`]+/)) {
191
+ if (raw.length === 0) {
192
+ continue;
193
+ }
194
+ for (const candidate of new Set([raw, raw.replace(DECORATION_RE, '')])) {
195
+ const token = candidate.replace(EDGE_TRIM_RE, '');
196
+ if (token.length > 0) {
197
+ out.push(token);
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /** Basename stem (no `.md`) of a path-or-id token. */
205
+ function stemOf(token: string): string {
206
+ const base = token.replace(/\\/g, '/').split('/').pop() ?? token;
207
+ return base.replace(/\.md$/, '');
208
+ }
209
+
210
+ /** Dotted settings-key shape — `personal.autonomy`, `tokens.rich_skills`, … */
211
+ const DOTTED_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/;
212
+
213
+ /**
214
+ * The surfaces through which enforcement is actually armed or disarmed. A
215
+ * workflow file is only ONE of them, and in this repo it is not even the usual
216
+ * one — a new gate is registered in the taskfiles the workflow calls, and a
217
+ * tool-call-time guard is armed in the hook manifest. Each entry, and why it
218
+ * arms enforcement:
219
+ *
220
+ * - `.github/workflows/` — the CI job definitions; deleting a step removes
221
+ * the gate from every PR. (The leading dot is stripped by `denyTokens`, so
222
+ * the match is on the segment pair, not the dotfile spelling.)
223
+ * - `Taskfile.yml` / `taskfiles/*.yml` — the task graph the workflows invoke
224
+ * (`task ci`, `task ci-fast`). A gate that is not listed here does not run,
225
+ * however green the workflow looks.
226
+ * - `src/scripts/hook_manifest.yaml` — the concern↔slot bindings. Unbinding
227
+ * a concern disarms a PreToolUse guard without touching the guard's code.
228
+ * - `src/scripts/hooks/` — the guard implementations themselves
229
+ * (`block_no_verify`, `block_kernel_rule_writes`, `block_config_weakening`).
230
+ * - `src/scripts/check_*.ts` / `src/scripts/lint_*.ts` — the gate scripts. A
231
+ * loosened threshold or a narrowed scan root disarms the gate in place,
232
+ * which is exactly the "gates that scan nothing exit green" failure.
233
+ *
234
+ * Deliberately NOT the whole of `src/scripts/`: that would deny nearly every
235
+ * legitimate repair this loop exists to file.
236
+ */
237
+ function isCiEnforcementPath(token: string): boolean {
238
+ const t = token.replace(/\\/g, '/');
239
+ if (/(^|\/)github\/workflows\//.test(t)) {
240
+ return true;
241
+ }
242
+ if (/(^|\/)Taskfile(\.[a-z0-9_-]+)?\.ya?ml$/i.test(t)) {
243
+ return true;
244
+ }
245
+ if (/(^|\/)taskfiles\//.test(t)) {
246
+ return true;
247
+ }
248
+ if (/(^|\/)hook_manifest\.ya?ml$/.test(t)) {
249
+ return true;
250
+ }
251
+ if (/(^|\/)scripts\/hooks\//.test(t)) {
252
+ return true;
253
+ }
254
+ return /(^|\/)scripts\/(check|lint)_[a-z0-9_]+\.ts$/.test(t);
255
+ }
256
+
257
+ /**
258
+ * Is this record's fix aimed at a denied surface? Scans the two fields that
259
+ * name what the fix should change (`suggested_surface`) and what was observed
260
+ * (`evidence`). A match errs toward denial on purpose — the cost of a false
261
+ * positive is a report instead of a patch, still egress; the cost of a false
262
+ * negative is a patch channel into a safety floor.
263
+ *
264
+ * The kernel set resolves from `kernel_rules.ts` — the same canonical source
265
+ * `block_kernel_rule_writes` uses — never a copy that can drift. Settings
266
+ * class-C keys are matched against `classCKeys` when the caller can supply
267
+ * them (parsed from `docs/contracts/settings-classes.md`); the contract file
268
+ * itself is denied by path regardless, so an unreadable contract narrows the
269
+ * dynamic check without opening the fence's own definition to patching.
270
+ */
271
+ export function patchDeniedSurface(
272
+ record: DefectFinding,
273
+ opts?: { classCKeys?: ReadonlySet<string> | null },
274
+ ): PatchDenial | null {
275
+ const classCKeys = opts?.classCKeys ?? null;
276
+ for (const token of denyTokens(`${record.suggested_surface}\n${record.evidence}`)) {
277
+ // Checked before `ci-enforcement` so a self-repair file that also lives
278
+ // under an enforcement path (`src/scripts/hooks/self_repair_hook.ts`)
279
+ // reports the more specific surface. Either way it is denied.
280
+ if (/self[-_]repair/i.test(token)) {
281
+ return { surface: 'self-repair-policy', match: token };
282
+ }
283
+ if (isCiEnforcementPath(token)) {
284
+ return { surface: 'ci-enforcement', match: token };
285
+ }
286
+ if (token.includes('settings-classes.md')) {
287
+ return { surface: 'settings-class-c', match: token };
288
+ }
289
+ if (is_kernel_rule(token)) {
290
+ return { surface: 'kernel-rule', match: token };
291
+ }
292
+ if (stemOf(token).endsWith('-safety-floor')) {
293
+ return { surface: 'safety-floor-rule', match: token };
294
+ }
295
+ if (classCKeys !== null && DOTTED_KEY_RE.test(token)) {
296
+ // Exact key first, then every classified ancestor down to the root
297
+ // segment — a class-C key whose value is a MAP has children that
298
+ // never appear under the key's own name.
299
+ const parts = token.split('.');
300
+ for (let end = parts.length; end > 0; end--) {
301
+ if (classCKeys.has(parts.slice(0, end).join('.'))) {
302
+ return { surface: 'settings-class-c', match: token };
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** The named refusal the patch/PR path reports. Null = patch path allowed. */
311
+ export function patchDeniedReason(
312
+ record: DefectFinding,
313
+ opts?: { classCKeys?: ReadonlySet<string> | null },
314
+ ): string | null {
315
+ const denial = patchDeniedSurface(record, opts);
316
+ if (denial === null) {
317
+ return null;
318
+ }
319
+ return (
320
+ `self-repair may not patch a ${denial.surface} surface (matched: ${denial.match}) — ` +
321
+ 'the pull-request path is refused; the record egresses report-only (issue / local record)'
322
+ );
323
+ }
324
+
131
325
  // ── detectors ──────────────────────────────────────────────────────
132
326
 
133
327
  // An agent-directed complaint, not a complaint about the code. Deliberately
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Session end-of-life scanner (road-to-token-economy-recycling Phase 1).
3
+ *
4
+ * Pure counting over Claude Code transcript JSONL: per session, the FINAL
5
+ * main-chain context size in parsed tokens, the turn count, and every
6
+ * compaction event — the three numbers the recycle roadmap's baseline and
7
+ * threshold are built from. Token math delegates to `cc_transcript.ts`
8
+ * (`billableInputTokens` — input + cache_read + cache_creation); this module
9
+ * adds no second token definition ("no new parser", roadmap prerequisite).
10
+ *
11
+ * Compaction detector — pinned to OBSERVED reality (blocker
12
+ * `compaction-marker-shape`): a real auto-compaction recorded 2026-08-06 on
13
+ * host v2.1.222 writes TWO markers into the transcript:
14
+ *
15
+ * 1. `{"type":"system","subtype":"compact_boundary","compactMetadata":
16
+ * {"trigger":"auto","preTokens":1000410,"postTokens":15870,...}}`
17
+ * 2. a `{"type":"user","isCompactSummary":true,...}` record carrying the
18
+ * summary the session was restarted from.
19
+ *
20
+ * The fixture in `tests/scripts/_lib_session_eol.test.ts` freezes both
21
+ * shapes. Never-silent discipline: the scanner counts BOTH markers
22
+ * independently — `compactions[]` from the boundary records,
23
+ * `compact_summaries` from the summary records. A host update that changes
24
+ * one shape but not the other makes the two counters diverge, which the
25
+ * report surfaces as marker drift instead of silently zeroing the metric.
26
+ *
27
+ * Incremental by design: the Stop-slot carrier re-runs every turn, so
28
+ * `scanEolSlice` accepts prior counters and only the newly appended complete
29
+ * lines (`readNewCompleteLines`). A full rescan is only ever the bounded
30
+ * 50 MB `isSafeTranscriptPath` cap away.
31
+ */
32
+
33
+ import * as fs from 'node:fs';
34
+
35
+ import { billableInputTokens } from './cc_transcript.js';
36
+
37
+ /** The observed compaction boundary marker (host v2.1.222, 2026-08-06). */
38
+ export const COMPACT_BOUNDARY_SUBTYPE = 'compact_boundary';
39
+
40
+ /** One compaction event, read from a `compact_boundary` system record. */
41
+ export interface CompactionEvent {
42
+ /** `"auto"` observed; the field is host territory — kept as an open string. */
43
+ trigger: string;
44
+ /** Context tokens immediately before compaction (`compactMetadata.preTokens`). */
45
+ pre_tokens: number | null;
46
+ /** Context tokens immediately after (`compactMetadata.postTokens`). */
47
+ post_tokens: number | null;
48
+ timestamp: string | null;
49
+ }
50
+
51
+ /** Accumulated per-session end-of-life counters. Counts and refs only — never content. */
52
+ export interface EolCounters {
53
+ schema_version: 1;
54
+ /** Byte offset up to which the transcript has been consumed (end of last complete line). */
55
+ scanned_bytes: number;
56
+ /** External user prompts on the main chain (excl. meta / compact-summary records). */
57
+ turns: number;
58
+ /** Main-chain assistant records carrying a `usage` block. */
59
+ assistant_records: number;
60
+ /** Billable input tokens of the LAST main-chain assistant record — the fill level. */
61
+ final_context_tokens: number | null;
62
+ /** `timestamp` of that record. */
63
+ final_context_at: string | null;
64
+ /** Every `compact_boundary` event seen, in order. */
65
+ compactions: CompactionEvent[];
66
+ /** `isCompactSummary` user records seen — the drift twin of `compactions`. */
67
+ compact_summaries: number;
68
+ /** Lines that failed `JSON.parse`. */
69
+ bad_lines: number;
70
+ }
71
+
72
+ export function emptyCounters(): EolCounters {
73
+ return {
74
+ schema_version: 1,
75
+ scanned_bytes: 0,
76
+ turns: 0,
77
+ assistant_records: 0,
78
+ final_context_tokens: null,
79
+ final_context_at: null,
80
+ compactions: [],
81
+ compact_summaries: 0,
82
+ bad_lines: 0,
83
+ };
84
+ }
85
+
86
+ function numOrNull(value: unknown): number | null {
87
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
88
+ }
89
+
90
+ function numOr0(value: unknown): number {
91
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
92
+ }
93
+
94
+ /**
95
+ * Detect the observed compaction boundary marker on one parsed record.
96
+ * Returns the event, or `null` for every other record shape.
97
+ */
98
+ export function detectCompaction(record: Record<string, unknown>): CompactionEvent | null {
99
+ if (record['type'] !== 'system' || record['subtype'] !== COMPACT_BOUNDARY_SUBTYPE) {
100
+ return null;
101
+ }
102
+ const md = (record['compactMetadata'] ?? {}) as Record<string, unknown>;
103
+ return {
104
+ trigger: typeof md['trigger'] === 'string' ? md['trigger'] : 'unknown',
105
+ pre_tokens: numOrNull(md['preTokens']),
106
+ post_tokens: numOrNull(md['postTokens']),
107
+ timestamp: typeof record['timestamp'] === 'string' ? record['timestamp'] : null,
108
+ };
109
+ }
110
+
111
+ /** True for a main-chain external user PROMPT record (a turn), per the baseline heuristic. */
112
+ function isTurnRecord(record: Record<string, unknown>): boolean {
113
+ if (record['type'] !== 'user') return false;
114
+ if (record['isCompactSummary'] === true || record['isMeta'] === true) return false;
115
+ const message = record['message'];
116
+ if (typeof message !== 'object' || message === null) return false;
117
+ const content = (message as Record<string, unknown>)['content'];
118
+ if (typeof content === 'string') return content.trim().length > 0;
119
+ if (Array.isArray(content)) {
120
+ return content.some(
121
+ (b) => typeof b === 'object' && b !== null && (b as Record<string, unknown>)['type'] === 'text',
122
+ );
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Fold newly appended COMPLETE transcript lines into the counters. Pure —
129
+ * returns a new counters object; `scanned_bytes` is the caller's business
130
+ * (it knows the byte offsets, this function only sees text).
131
+ */
132
+ export function scanEolSlice(text: string, prior: EolCounters): EolCounters {
133
+ const next: EolCounters = {
134
+ ...prior,
135
+ compactions: [...prior.compactions],
136
+ };
137
+ for (const line of text.split('\n')) {
138
+ if (line.trim().length === 0) continue;
139
+ let record: Record<string, unknown>;
140
+ try {
141
+ const parsed = JSON.parse(line) as unknown;
142
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
143
+ next.bad_lines += 1;
144
+ continue;
145
+ }
146
+ record = parsed as Record<string, unknown>;
147
+ } catch {
148
+ next.bad_lines += 1;
149
+ continue;
150
+ }
151
+ if (record['isSidechain'] === true) continue; // subagent legs are not this session's context
152
+
153
+ const compaction = detectCompaction(record);
154
+ if (compaction) {
155
+ next.compactions.push(compaction);
156
+ continue;
157
+ }
158
+ if (record['type'] === 'user' && record['isCompactSummary'] === true) {
159
+ next.compact_summaries += 1;
160
+ continue;
161
+ }
162
+ if (isTurnRecord(record)) {
163
+ next.turns += 1;
164
+ continue;
165
+ }
166
+ if (record['type'] === 'assistant') {
167
+ const message = record['message'];
168
+ const usage =
169
+ typeof message === 'object' && message !== null
170
+ ? ((message as Record<string, unknown>)['usage'] as Record<string, unknown> | undefined)
171
+ : undefined;
172
+ if (typeof usage === 'object' && usage !== null && usage['input_tokens'] !== undefined) {
173
+ next.assistant_records += 1;
174
+ next.final_context_tokens = billableInputTokens({
175
+ input_tokens: numOr0(usage['input_tokens']),
176
+ cache_read_input_tokens: numOr0(usage['cache_read_input_tokens']),
177
+ cache_creation_input_tokens: numOr0(usage['cache_creation_input_tokens']),
178
+ });
179
+ next.final_context_at =
180
+ typeof record['timestamp'] === 'string' ? record['timestamp'] : null;
181
+ }
182
+ }
183
+ }
184
+ return next;
185
+ }
186
+
187
+ export interface NewLinesRead {
188
+ /** The complete lines appended since `fromByte` (possibly empty). */
189
+ text: string;
190
+ /** The offset to persist — end of the last complete line consumed. */
191
+ nextOffset: number;
192
+ /** True when the file shrank below `fromByte` (rotation/truncation) — caller rescans from 0. */
193
+ truncated: boolean;
194
+ }
195
+
196
+ /**
197
+ * Read the bytes appended to `filePath` since `fromByte`, trimmed to the
198
+ * last complete line (a JSONL writer may be mid-line). Never throws — any
199
+ * fs error reads as "nothing new".
200
+ */
201
+ export function readNewCompleteLines(filePath: string, fromByte: number): NewLinesRead {
202
+ try {
203
+ const { size } = fs.statSync(filePath);
204
+ if (size < fromByte) {
205
+ return { text: '', nextOffset: 0, truncated: true };
206
+ }
207
+ if (size === fromByte) {
208
+ return { text: '', nextOffset: fromByte, truncated: false };
209
+ }
210
+ const length = size - fromByte;
211
+ const buffer = Buffer.alloc(length);
212
+ const fd = fs.openSync(filePath, 'r');
213
+ try {
214
+ fs.readSync(fd, buffer, 0, length, fromByte);
215
+ } finally {
216
+ fs.closeSync(fd);
217
+ }
218
+ const lastNewline = buffer.lastIndexOf(0x0a);
219
+ if (lastNewline === -1) {
220
+ return { text: '', nextOffset: fromByte, truncated: false };
221
+ }
222
+ return {
223
+ text: buffer.subarray(0, lastNewline + 1).toString('utf-8'),
224
+ nextOffset: fromByte + lastNewline + 1,
225
+ truncated: false,
226
+ };
227
+ } catch {
228
+ return { text: '', nextOffset: fromByte, truncated: false };
229
+ }
230
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Session-role detection — the ONE shared detector (road-to-token-economy-
3
+ * dispatch Phase 2.2: "one detector, two consumers, test-pinned equal").
4
+ *
5
+ * Consumers:
6
+ * - `hooks/dispatch_hook.ts` — applies the manifest's role axis (a marked
7
+ * worker session runs a shorter concern chain).
8
+ * - `hooks/delegation_nudge_hook.ts` — feeds the judgment ladder's
9
+ * recursive-dispatch guard (`insideSubagentSession`), whose own header
10
+ * documents the fact as CALLER-SUPPLIED: no verified host discriminator
11
+ * exists (upstream agent-identity request closed NOT_PLANNED), and
12
+ * probing for invented host variable names is the hallucinated-field
13
+ * failure `source-discovery-gate` exists to stop.
14
+ *
15
+ * `AGENT_CONFIG_SESSION_ROLE` is therefore this suite's OWN variable, set
16
+ * only by suite-owned spawn wrappers that launch a separate CLI session
17
+ * (council transports, bench runners, future worker wrappers). Host
18
+ * limitation, pre-registered in the roadmap's `worker-chain-host-delivery`
19
+ * blocker: an in-process Agent-tool subagent shares the host process env
20
+ * and CANNOT be marked per-spawn — on that path the role resolves
21
+ * `orchestrator` and the full chain runs (fail-open, Phase 2.4).
22
+ *
23
+ * Fail-open is the contract: unset, empty, or unknown values resolve to
24
+ * `orchestrator` — the thin path is the opt-in of a marked spawn, never
25
+ * the accident of a missing or typo'd variable.
26
+ */
27
+
28
+ export const SESSION_ROLE_ENV = 'AGENT_CONFIG_SESSION_ROLE';
29
+
30
+ /** `reviewer` is enum-reserved for Phase 3.2; until a `roles.reviewer`
31
+ * manifest entry exists it resolves to the full chain like any role
32
+ * without a manifest entry. */
33
+ export type SessionRole = 'orchestrator' | 'worker' | 'reviewer';
34
+
35
+ const KNOWN_ROLES: ReadonlySet<string> = new Set(['orchestrator', 'worker', 'reviewer']);
36
+
37
+ export function resolveSessionRole(
38
+ env: Record<string, string | undefined> = process.env,
39
+ ): SessionRole {
40
+ const raw = (env[SESSION_ROLE_ENV] ?? '').trim().toLowerCase();
41
+ return (KNOWN_ROLES.has(raw) ? raw : 'orchestrator') as SessionRole;
42
+ }
@@ -82,6 +82,23 @@ export interface WorkerCapsule {
82
82
  assumptions: CapsuleAssumption[];
83
83
  }
84
84
 
85
+ /**
86
+ * Capsule schema version (road-to-token-economy-recycling Phase 2.1).
87
+ * Versioning is INTRODUCED here — the Phase-0 worker capsule shipped
88
+ * unversioned and stays valid without a version field (implicit v1, wire
89
+ * shape unchanged, `subagent-status.json` untouched). Version 2 adds the
90
+ * explicit `capsule_version` + `variant` discriminator that the
91
+ * main-session recycle envelope requires. Policy: additive only — a new
92
+ * version may add fields or variants, never repurpose or remove one; both
93
+ * variants validate through THIS module (one schema file, one validator
94
+ * family — roadmap 5.6's anti-fork rule).
95
+ */
96
+ export const CAPSULE_SCHEMA_VERSION = 2;
97
+
98
+ /** The two CHECKPOINT variants sharing this schema. */
99
+ export const CAPSULE_VARIANTS = ['worker', 'main_session'] as const;
100
+ export type CapsuleVariant = (typeof CAPSULE_VARIANTS)[number];
101
+
85
102
  /** A single-line token no longer than `max` chars. Rejects any body-shaped value. */
86
103
  function isShortLine(value: unknown, max: number): value is string {
87
104
  return typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\n');
@@ -158,6 +175,148 @@ export function validateCapsule(input: unknown): string[] {
158
175
  return errors;
159
176
  }
160
177
 
178
+ /**
179
+ * The main-session recycle envelope (road-to-token-economy-recycling
180
+ * Phase 2.1) — the `main_session` CHECKPOINT variant. Unlike the worker
181
+ * capsule there is NO surviving orchestrator to receive it: the successor
182
+ * session bootstraps from the envelope ALONE, so the required set is
183
+ * stricter — the active task, its acceptance criteria, the open work, and
184
+ * the explicit NOT-carried-forward list (what the successor must re-derive
185
+ * from source rather than trust) are all mandatory.
186
+ *
187
+ * Same transcript-exclusion-by-construction as the worker capsule: every
188
+ * field is a ref token or a single short line, validated by the SAME
189
+ * primitives. A prose transcript summary is schema-invalid twice over —
190
+ * any multi-line value fails `isShortLine`, and any field not listed below
191
+ * (e.g. `transcript_summary`) fails the unknown-key sweep. The strict
192
+ * sweep is deliberate where the worker validator stays open: the worker
193
+ * side has `subagent-status.json` (`additionalProperties: false`) as its
194
+ * wire gate; this variant is consumed from a FILE by
195
+ * `handoff_context_hook`, so the module itself must be the strict gate.
196
+ */
197
+ export interface MainSessionRecycleEnvelope {
198
+ /** Must be {@link CAPSULE_SCHEMA_VERSION}. */
199
+ capsule_version: number;
200
+ variant: 'main_session';
201
+ /** One- or two-sentence outcome so far. */
202
+ summary: string;
203
+ /** The active task, one line. */
204
+ task: string;
205
+ /** Workspace root this envelope belongs to — the consumer's identity check (Risk 4). */
206
+ workspace: string;
207
+ /** ISO-8601 stamp of the write — the consumer's staleness guard. */
208
+ written_at: string;
209
+ /** What "done" means — one short line each. Required, ≥ 1. */
210
+ acceptance_criteria: string[];
211
+ /** Open work, one short line each. Required (may be empty when only verification remains). */
212
+ remaining: string[];
213
+ /**
214
+ * What the successor must RE-DERIVE from source instead of trusting —
215
+ * the explicit omission list that makes envelope loss visible instead
216
+ * of silent. Required; an empty list is a claim, not a default.
217
+ */
218
+ not_carried_forward: string[];
219
+ /** Choices already made, each with its one-line rationale in the same line. */
220
+ decisions?: string[];
221
+ /** Binding constraints the successor must not silently re-open. */
222
+ constraints?: string[];
223
+ /** Open worker CHECKPOINT envelopes by path — never inlined bodies. */
224
+ open_worker_envelopes?: string[];
225
+ /** Artifact paths (deliverables, notes, evidence) — refs, never bodies. */
226
+ artifact_paths?: string[];
227
+ /** Stated premises — the shared `{statement, basis, epistemic_state}` shape. */
228
+ assumptions?: CapsuleAssumption[];
229
+ }
230
+
231
+ /** Every key `validateRecycleEnvelope` accepts — anything else is schema-invalid. */
232
+ const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
233
+ 'capsule_version',
234
+ 'variant',
235
+ 'summary',
236
+ 'task',
237
+ 'workspace',
238
+ 'written_at',
239
+ 'acceptance_criteria',
240
+ 'remaining',
241
+ 'not_carried_forward',
242
+ 'decisions',
243
+ 'constraints',
244
+ 'open_worker_envelopes',
245
+ 'artifact_paths',
246
+ 'assumptions',
247
+ ]);
248
+
249
+ /**
250
+ * Validate a main-session recycle envelope. Returns the list of violations
251
+ * (empty = valid). The consumer never injects an invalid envelope — it is
252
+ * consumed and discarded loudly instead (`handoff_context_hook`).
253
+ */
254
+ export function validateRecycleEnvelope(input: unknown): string[] {
255
+ const errors: string[] = [];
256
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
257
+ return ['not an object'];
258
+ }
259
+ const e = input as Record<string, unknown>;
260
+
261
+ for (const key of Object.keys(e)) {
262
+ if (!RECYCLE_ENVELOPE_KEYS.has(key)) {
263
+ errors.push(
264
+ `unknown field "${key}" — a recycle envelope carries selection and pointers only; ` +
265
+ 'free-form additions (prose summaries included) are schema-invalid by construction',
266
+ );
267
+ }
268
+ }
269
+
270
+ if (e['capsule_version'] !== CAPSULE_SCHEMA_VERSION) {
271
+ errors.push(`capsule_version must be ${CAPSULE_SCHEMA_VERSION}`);
272
+ }
273
+ if (e['variant'] !== 'main_session') {
274
+ errors.push("variant must be 'main_session'");
275
+ }
276
+ if (!isShortLine(e['summary'], MAX_LINE_CHARS)) {
277
+ errors.push(`summary must be a single line of 1–${MAX_LINE_CHARS} chars`);
278
+ }
279
+ if (!isShortLine(e['task'], MAX_LINE_CHARS)) {
280
+ errors.push(`task must be a single line of 1–${MAX_LINE_CHARS} chars`);
281
+ }
282
+ if (!isShortLine(e['workspace'], MAX_REF_CHARS)) {
283
+ errors.push(`workspace must be a path ref of ≤ ${MAX_REF_CHARS} chars`);
284
+ }
285
+ const writtenAt = e['written_at'];
286
+ if (typeof writtenAt !== 'string' || Number.isNaN(Date.parse(writtenAt))) {
287
+ errors.push('written_at must be a parseable ISO-8601 timestamp');
288
+ }
289
+
290
+ checkList(errors, 'acceptance_criteria', e['acceptance_criteria'], MAX_LINE_CHARS, true);
291
+ if (Array.isArray(e['acceptance_criteria']) && e['acceptance_criteria'].length === 0) {
292
+ errors.push('acceptance_criteria must carry at least one entry — the successor cannot know "done" without it');
293
+ }
294
+ checkList(errors, 'remaining', e['remaining'], MAX_LINE_CHARS, true);
295
+ checkList(errors, 'not_carried_forward', e['not_carried_forward'], MAX_LINE_CHARS, true);
296
+ checkList(errors, 'decisions', e['decisions'], MAX_LINE_CHARS, false);
297
+ checkList(errors, 'constraints', e['constraints'], MAX_LINE_CHARS, false);
298
+ checkList(errors, 'open_worker_envelopes', e['open_worker_envelopes'], MAX_REF_CHARS, false);
299
+ checkList(errors, 'artifact_paths', e['artifact_paths'], MAX_REF_CHARS, false);
300
+
301
+ // `remaining` and `not_carried_forward` must be PRESENT (checkList already
302
+ // enforces that) — empty arrays are legal, explicit claims.
303
+ const assumptions = e['assumptions'];
304
+ if (assumptions !== undefined) {
305
+ if (!Array.isArray(assumptions)) {
306
+ errors.push('assumptions must be an array');
307
+ } else {
308
+ if (assumptions.length > MAX_ENTRIES) {
309
+ errors.push(`assumptions carries ${assumptions.length} entries (max ${MAX_ENTRIES})`);
310
+ }
311
+ for (const [i, raw] of assumptions.entries()) {
312
+ errors.push(...validateAssumption(raw, `assumptions[${i}]`));
313
+ }
314
+ }
315
+ }
316
+
317
+ return errors;
318
+ }
319
+
161
320
  /**
162
321
  * Validate one `{statement, basis, epistemic_state}` triple. Exported because
163
322
  * the worker RESULT envelope carries the same field (`subagent_response.ts`) —