@event4u/agent-config 11.0.0 → 12.0.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 (249) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +71 -249
  3. package/dist/agent-src/commands/agent-handoff.md +0 -1
  4. package/dist/agent-src/commands/agent-status.md +0 -1
  5. package/dist/agent-src/commands/agents/audit.md +0 -1
  6. package/dist/agent-src/commands/agents/init.md +0 -1
  7. package/dist/agent-src/commands/agents/optimize.md +0 -1
  8. package/dist/agent-src/commands/agents/user/accept.md +0 -1
  9. package/dist/agent-src/commands/agents/user/delete.md +0 -1
  10. package/dist/agent-src/commands/agents/user/init.md +0 -1
  11. package/dist/agent-src/commands/agents/user/review.md +0 -1
  12. package/dist/agent-src/commands/agents/user/show.md +0 -1
  13. package/dist/agent-src/commands/agents/user/update.md +0 -1
  14. package/dist/agent-src/commands/agents/user.md +0 -1
  15. package/dist/agent-src/commands/agents.md +0 -1
  16. package/dist/agent-src/commands/analytics/prune.md +0 -1
  17. package/dist/agent-src/commands/analytics/show.md +0 -1
  18. package/dist/agent-src/commands/analytics.md +0 -1
  19. package/dist/agent-src/commands/analyze/conformance.md +0 -1
  20. package/dist/agent-src/commands/analyze/decision.md +0 -1
  21. package/dist/agent-src/commands/analyze/inbox.md +0 -1
  22. package/dist/agent-src/commands/analyze/incident.md +0 -1
  23. package/dist/agent-src/commands/analyze/near-miss.md +0 -1
  24. package/dist/agent-src/commands/analyze/postmortem.md +0 -1
  25. package/dist/agent-src/commands/analyze/premortem.md +0 -1
  26. package/dist/agent-src/commands/analyze/reference-repo.md +0 -1
  27. package/dist/agent-src/commands/analyze.md +0 -1
  28. package/dist/agent-src/commands/brand/identity.md +0 -1
  29. package/dist/agent-src/commands/brand/review.md +0 -1
  30. package/dist/agent-src/commands/brand/strategy.md +0 -1
  31. package/dist/agent-src/commands/brand/tokens.md +0 -1
  32. package/dist/agent-src/commands/brand/voice.md +0 -1
  33. package/dist/agent-src/commands/brand.md +0 -1
  34. package/dist/agent-src/commands/bug/fix.md +0 -1
  35. package/dist/agent-src/commands/bug/investigate.md +0 -1
  36. package/dist/agent-src/commands/bug.md +0 -1
  37. package/dist/agent-src/commands/challenge-me/vision.md +0 -1
  38. package/dist/agent-src/commands/challenge-me/with-docs.md +0 -1
  39. package/dist/agent-src/commands/challenge-me.md +0 -1
  40. package/dist/agent-src/commands/chat-history/import.md +0 -1
  41. package/dist/agent-src/commands/chat-history.md +0 -1
  42. package/dist/agent-src/commands/check-current-md.md +0 -1
  43. package/dist/agent-src/commands/commit/in-chunks.md +0 -1
  44. package/dist/agent-src/commands/commit.md +0 -1
  45. package/dist/agent-src/commands/condense.md +0 -1
  46. package/dist/agent-src/commands/context/create.md +0 -1
  47. package/dist/agent-src/commands/context/refactor.md +0 -1
  48. package/dist/agent-src/commands/context.md +0 -1
  49. package/dist/agent-src/commands/contribution-precheck.md +0 -1
  50. package/dist/agent-src/commands/cost/profile.md +0 -1
  51. package/dist/agent-src/commands/cost/report.md +0 -1
  52. package/dist/agent-src/commands/cost.md +0 -1
  53. package/dist/agent-src/commands/council/analysis.md +0 -1
  54. package/dist/agent-src/commands/council/debate.md +0 -1
  55. package/dist/agent-src/commands/council/default.md +0 -1
  56. package/dist/agent-src/commands/council/design.md +0 -1
  57. package/dist/agent-src/commands/council/optimize.md +0 -1
  58. package/dist/agent-src/commands/council/pr.md +0 -1
  59. package/dist/agent-src/commands/council.md +0 -1
  60. package/dist/agent-src/commands/design-system/capture.md +0 -1
  61. package/dist/agent-src/commands/design-system/generate.md +0 -1
  62. package/dist/agent-src/commands/design-system/import.md +0 -1
  63. package/dist/agent-src/commands/design-system.md +0 -1
  64. package/dist/agent-src/commands/estimate-ticket.md +0 -1
  65. package/dist/agent-src/commands/explain-run.md +0 -1
  66. package/dist/agent-src/commands/feature/dev.md +0 -1
  67. package/dist/agent-src/commands/feature/explore.md +0 -1
  68. package/dist/agent-src/commands/feature/plan.md +0 -1
  69. package/dist/agent-src/commands/feature/refactor.md +0 -1
  70. package/dist/agent-src/commands/feature/roadmap.md +0 -1
  71. package/dist/agent-src/commands/feature.md +0 -1
  72. package/dist/agent-src/commands/fix/ci.md +0 -1
  73. package/dist/agent-src/commands/fix/comments.md +0 -1
  74. package/dist/agent-src/commands/fix/portability.md +0 -1
  75. package/dist/agent-src/commands/fix/pr-comments-loop.md +0 -1
  76. package/dist/agent-src/commands/fix/pr-comments.md +0 -1
  77. package/dist/agent-src/commands/fix/quality.md +0 -1
  78. package/dist/agent-src/commands/fix/refs.md +0 -1
  79. package/dist/agent-src/commands/fix/route.md +0 -1
  80. package/dist/agent-src/commands/fix/seeder.md +0 -1
  81. package/dist/agent-src/commands/fix.md +0 -1
  82. package/dist/agent-src/commands/ghostwriter/delete.md +0 -1
  83. package/dist/agent-src/commands/ghostwriter/fetch.md +0 -1
  84. package/dist/agent-src/commands/ghostwriter/list.md +0 -1
  85. package/dist/agent-src/commands/ghostwriter/show.md +0 -1
  86. package/dist/agent-src/commands/ghostwriter/write.md +0 -1
  87. package/dist/agent-src/commands/ghostwriter.md +0 -1
  88. package/dist/agent-src/commands/grill-me.md +0 -1
  89. package/dist/agent-src/commands/humanize.md +0 -1
  90. package/dist/agent-src/commands/image/analyse.md +0 -1
  91. package/dist/agent-src/commands/image/create.md +0 -1
  92. package/dist/agent-src/commands/image/verify.md +0 -1
  93. package/dist/agent-src/commands/image.md +0 -1
  94. package/dist/agent-src/commands/implement-ticket.md +0 -1
  95. package/dist/agent-src/commands/jira-ticket.md +0 -1
  96. package/dist/agent-src/commands/judge/on-diff.md +0 -1
  97. package/dist/agent-src/commands/judge/solo.md +0 -1
  98. package/dist/agent-src/commands/judge/steps.md +0 -1
  99. package/dist/agent-src/commands/judge.md +0 -1
  100. package/dist/agent-src/commands/knowledge/cross-repo.md +0 -1
  101. package/dist/agent-src/commands/knowledge/forget.md +0 -1
  102. package/dist/agent-src/commands/knowledge/ingest.md +0 -1
  103. package/dist/agent-src/commands/knowledge/list.md +0 -1
  104. package/dist/agent-src/commands/knowledge.md +0 -1
  105. package/dist/agent-src/commands/memory/add.md +0 -1
  106. package/dist/agent-src/commands/memory/learn-low-impact.md +0 -1
  107. package/dist/agent-src/commands/memory/load.md +0 -1
  108. package/dist/agent-src/commands/memory/mine-session.md +0 -1
  109. package/dist/agent-src/commands/memory/promote.md +0 -1
  110. package/dist/agent-src/commands/memory/propose.md +0 -1
  111. package/dist/agent-src/commands/memory.md +0 -1
  112. package/dist/agent-src/commands/mission/upgrade.md +0 -1
  113. package/dist/agent-src/commands/mode.md +0 -1
  114. package/dist/agent-src/commands/module/create.md +0 -1
  115. package/dist/agent-src/commands/module/explore.md +0 -1
  116. package/dist/agent-src/commands/module.md +0 -1
  117. package/dist/agent-src/commands/optimize/agents-dir.md +0 -1
  118. package/dist/agent-src/commands/optimize/augmentignore.md +0 -1
  119. package/dist/agent-src/commands/optimize/deep.md +0 -1
  120. package/dist/agent-src/commands/optimize/project.md +0 -1
  121. package/dist/agent-src/commands/optimize/prompt.md +0 -1
  122. package/dist/agent-src/commands/optimize/rtk.md +0 -1
  123. package/dist/agent-src/commands/optimize/skills.md +0 -1
  124. package/dist/agent-src/commands/optimize.md +0 -1
  125. package/dist/agent-src/commands/orchestrate.md +0 -1
  126. package/dist/agent-src/commands/override/create.md +0 -1
  127. package/dist/agent-src/commands/override/manage.md +0 -1
  128. package/dist/agent-src/commands/override.md +0 -1
  129. package/dist/agent-src/commands/package/reset.md +0 -1
  130. package/dist/agent-src/commands/package/test.md +0 -1
  131. package/dist/agent-src/commands/package.md +0 -1
  132. package/dist/agent-src/commands/post-as/ghostwriter.md +0 -1
  133. package/dist/agent-src/commands/post-as/me.md +0 -1
  134. package/dist/agent-src/commands/post-as.md +0 -1
  135. package/dist/agent-src/commands/pr/create/description-only.md +0 -1
  136. package/dist/agent-src/commands/pr/create.md +23 -9
  137. package/dist/agent-src/commands/prediction-pool.md +0 -1
  138. package/dist/agent-src/commands/prepare-for-review.md +0 -1
  139. package/dist/agent-src/commands/profile/activate.md +0 -1
  140. package/dist/agent-src/commands/profile/deactivate.md +0 -1
  141. package/dist/agent-src/commands/profile/show.md +0 -1
  142. package/dist/agent-src/commands/profile.md +0 -1
  143. package/dist/agent-src/commands/project/analyze.md +0 -1
  144. package/dist/agent-src/commands/project/health.md +0 -1
  145. package/dist/agent-src/commands/project.md +0 -1
  146. package/dist/agent-src/commands/refine-ticket.md +0 -1
  147. package/dist/agent-src/commands/research/deep.md +0 -1
  148. package/dist/agent-src/commands/research/report.md +0 -1
  149. package/dist/agent-src/commands/research.md +0 -1
  150. package/dist/agent-src/commands/review/changes.md +0 -1
  151. package/dist/agent-src/commands/review/routing.md +0 -1
  152. package/dist/agent-src/commands/review.md +0 -1
  153. package/dist/agent-src/commands/roadmap/ai-council.md +0 -1
  154. package/dist/agent-src/commands/roadmap/create.md +0 -1
  155. package/dist/agent-src/commands/roadmap/materialize.md +0 -1
  156. package/dist/agent-src/commands/roadmap/next.md +0 -1
  157. package/dist/agent-src/commands/roadmap/process-full.md +0 -1
  158. package/dist/agent-src/commands/roadmap/process-phase.md +0 -1
  159. package/dist/agent-src/commands/roadmap/process-step.md +0 -1
  160. package/dist/agent-src/commands/roadmap.md +0 -1
  161. package/dist/agent-src/commands/rule-compliance-audit.md +0 -1
  162. package/dist/agent-src/commands/security-audit-config.md +0 -1
  163. package/dist/agent-src/commands/skill/preview.md +0 -1
  164. package/dist/agent-src/commands/skill.md +0 -1
  165. package/dist/agent-src/commands/skills/discover.md +0 -1
  166. package/dist/agent-src/commands/skills.md +0 -1
  167. package/dist/agent-src/commands/sync/agent-settings.md +0 -1
  168. package/dist/agent-src/commands/sync/gitignore/fix.md +0 -1
  169. package/dist/agent-src/commands/sync/gitignore.md +0 -1
  170. package/dist/agent-src/commands/sync.md +0 -1
  171. package/dist/agent-src/commands/tdd/green.md +0 -1
  172. package/dist/agent-src/commands/tdd/red.md +0 -1
  173. package/dist/agent-src/commands/tdd/refactor.md +0 -1
  174. package/dist/agent-src/commands/tdd.md +0 -1
  175. package/dist/agent-src/commands/team/adversarial.md +0 -1
  176. package/dist/agent-src/commands/team/delegate.md +0 -1
  177. package/dist/agent-src/commands/team/review.md +0 -1
  178. package/dist/agent-src/commands/team/status.md +0 -1
  179. package/dist/agent-src/commands/team-knowledge/bootstrap.md +0 -1
  180. package/dist/agent-src/commands/team-knowledge/consolidate.md +0 -1
  181. package/dist/agent-src/commands/team-knowledge.md +0 -1
  182. package/dist/agent-src/commands/team.md +0 -1
  183. package/dist/agent-src/commands/tests/create.md +0 -1
  184. package/dist/agent-src/commands/tests/e2e-heal.md +0 -1
  185. package/dist/agent-src/commands/tests/e2e-plan.md +0 -1
  186. package/dist/agent-src/commands/tests/execute.md +0 -1
  187. package/dist/agent-src/commands/tests.md +0 -1
  188. package/dist/agent-src/commands/threat-model.md +0 -1
  189. package/dist/agent-src/commands/update-form-request-messages.md +0 -1
  190. package/dist/agent-src/commands/upstream-contribute.md +0 -1
  191. package/dist/agent-src/commands/video/from-script.md +0 -1
  192. package/dist/agent-src/commands/video/from-song.md +0 -1
  193. package/dist/agent-src/commands/video/scene.md +0 -1
  194. package/dist/agent-src/commands/video/stitch.md +0 -1
  195. package/dist/agent-src/commands/video/storyboard.md +0 -1
  196. package/dist/agent-src/commands/video.md +0 -1
  197. package/dist/agent-src/commands/work.md +0 -1
  198. package/dist/agent-src/commands/worktree/cleanup.md +0 -1
  199. package/dist/agent-src/commands/worktree/create.md +0 -1
  200. package/dist/agent-src/commands/worktree/status.md +0 -1
  201. package/dist/agent-src/commands/worktree/verify.md +0 -1
  202. package/dist/agent-src/commands/worktree.md +0 -1
  203. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -0
  204. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -1
  205. package/dist/agent-src/skills/pdf-tools/SKILL.md +1 -0
  206. package/dist/agent-src/skills/skill-writing/SKILL.md +35 -0
  207. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  208. package/dist/cli/commands/commands.candidates.test.js +1 -1
  209. package/dist/cli/commands/commands.candidates.test.js.map +1 -1
  210. package/dist/cli/commands/commands.js +9 -15
  211. package/dist/cli/commands/commands.js.map +1 -1
  212. package/dist/cli/commands/profiles.test.js +7 -7
  213. package/dist/cli/commands/profiles.test.js.map +1 -1
  214. package/dist/cli/discovery/loadManifest.js.map +1 -1
  215. package/dist/cli/main.js +2 -2
  216. package/dist/cli/main.js.map +1 -1
  217. package/dist/cli-delegate/cmd_conformance.js +14 -2
  218. package/dist/discovery/deprecation-report.md +1 -1
  219. package/dist/discovery/discovery-manifest.json +211 -410
  220. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  221. package/dist/discovery/discovery-manifest.summary.md +2 -2
  222. package/dist/discovery/dormancy-report.md +1 -1
  223. package/dist/discovery/orphan-report.md +1 -1
  224. package/dist/discovery/packs.json +3 -3
  225. package/dist/discovery/trust-report.md +1 -1
  226. package/dist/discovery/workspaces.json +3 -3
  227. package/dist/hooks/dispatch.js +14 -2
  228. package/dist/mcp/registry-manifest.json +3 -3
  229. package/dist/mcp/server.json +2 -2
  230. package/dist/mcp/server.mjs +46 -5
  231. package/docs/MIGRATION.md +1 -0
  232. package/docs/contracts/settings-classes.md +41 -0
  233. package/package.json +1 -1
  234. package/src/config/agent-settings.template.yml +34 -0
  235. package/src/config/gate-violation-baselines.json +3 -3
  236. package/src/scripts/_dispatch.bash +42 -5
  237. package/src/scripts/audit_command_surface.ts +21 -15
  238. package/src/scripts/build_catalog_index.ts +1 -1
  239. package/src/scripts/build_discovery_manifest.ts +30 -34
  240. package/src/scripts/check_branch_freshness.ts +210 -61
  241. package/src/scripts/gen_discovery_baseline.ts +3 -3
  242. package/src/scripts/hook_manifest.yaml +20 -0
  243. package/src/scripts/hooks/subagent_ledger_hook.ts +17 -4
  244. package/src/scripts/lint_command_tiers.ts +29 -75
  245. package/src/scripts/lint_originality.ts +101 -14
  246. package/src/scripts/schemas/command.schema.json +4 -9
  247. package/src/scripts/schemas/skill.schema.json +10 -1
  248. package/src/scripts/skill_linter.ts +82 -2
  249. package/src/scripts/worktree_cleanup_check.ts +111 -7
@@ -657,26 +657,13 @@ function _build(strict: boolean): [JsonObject, JsonObject[]] {
657
657
  // 6.0.0-C: surface command routing metadata. Does not affect the
658
658
  // per-file checksum (computed over frontmatter, below).
659
659
  if (category === 'command' && fm !== null && _isObject(fm)) {
660
- if (fm['tier'] !== undefined && fm['tier'] !== null) {
661
- entry['tier'] = fm['tier'];
662
- }
663
- // ADR-090: `visibility:` is the named source of truth; the integer
664
- // `tier:` is a back-compat alias. Dual-emit BOTH into the manifest
665
- // (a published data contract) during the deprecation window so
666
- // external consumers reading the integer key keep working. The
667
- // top-level `deprecations` block (ADR-092 / road-to-tier-removal)
668
- // announces the `tier` deprecation; removal is Phase 4. Prefer the
669
- // explicit field; derive from tier when absent.
670
- const _tierToVis: Record<number, string> = { 0: 'visible', 1: 'advanced', 2: 'internal' };
671
- let _vis: Json = fm['visibility'];
672
- if (
673
- (_vis === undefined || _vis === null) &&
674
- fm['tier'] !== undefined &&
675
- fm['tier'] !== null
676
- ) {
677
- // `.get(fm["tier"])` returns None when tier is not 0/1/2 → undefined here.
678
- _vis = typeof fm['tier'] === 'number' ? _tierToVis[fm['tier']] : undefined;
679
- }
660
+ // ADR-090 named `visibility:` the source of truth; the integer
661
+ // `tier:` alias it superseded was dual-emitted here through the
662
+ // deprecation window and REMOVED in road-to-tier-removal Phase 4.
663
+ // The manifest no longer carries `tier` in any entry — the
664
+ // top-level `deprecations` block records the removal for consumers
665
+ // that still read the old key.
666
+ const _vis: Json = fm['visibility'];
680
667
  if (_vis !== undefined && _vis !== null) {
681
668
  entry['visibility'] = _vis;
682
669
  }
@@ -788,27 +775,36 @@ function _build(strict: boolean): [JsonObject, JsonObject[]] {
788
775
  const stats = _compute_stats(artefacts, unassigned, documentedUnassigned);
789
776
 
790
777
  const manifest: JsonObject = {
791
- version: 2,
792
- // Machine-readable deprecation signal (road-to-tier-removal Phase 1,
793
- // ADR-092). The integer command `tier` (0/1/2) is a back-compat alias
794
- // for the named `visibility` field (ADR-090). It is STILL emitted into
795
- // command entries above (non-breaking) — this block only ANNOUNCES the
796
- // deprecation so external manifest consumers can migrate to `visibility`
797
- // during the soak window. Removal of `tier` is Phase 4 of
798
- // road-to-tier-removal (cheaply reversible per ADR-092); `sunset` is
799
- // maintainer-owned and stays null until the soak clears.
778
+ // v3 (road-to-tier-removal Phase 4): the integer command `tier` is no
779
+ // longer emitted on `category: command` artefacts. That is a breaking
780
+ // change to a published data contract, and this schema's own rule is
781
+ // "bump on breaking change" — so the version carries the break rather
782
+ // than leaving a v2 consumer to discover a silently-absent field.
783
+ version: 3,
784
+ // Machine-readable removal record (road-to-tier-removal Phase 1 added
785
+ // this block as a deprecation ANNOUNCEMENT; Phase 4 turned it into the
786
+ // record of an executed removal). The entry is retained on purpose: a
787
+ // consumer that still reads the old key needs to find out what replaced
788
+ // it and when it went, and an absent entry would say neither. `sunset`
789
+ // is maintainer-owned: SET on 2026-08-13, with the soak wait explicitly
790
+ // waived ("wir haben lange genug gewartet"). ADR-137 had already reduced
791
+ // the re-open trigger set to this one act — a concrete sunset — after
792
+ // withdrawing the second trigger as structurally impossible in a
793
+ // no-runtime package.
800
794
  deprecations: [
801
795
  {
802
796
  key: 'tier',
803
797
  scope: 'command',
804
798
  replacement: 'visibility',
805
799
  since: 'ADR-092',
806
- sunset: null,
800
+ sunset: '2026-08-13',
801
+ removed_in: 3,
807
802
  note:
808
- 'Integer command `tier` (0/1/2) is a deprecated back-compat ' +
809
- 'alias for the named `visibility` field (ADR-090). Read ' +
810
- '`visibility`; `tier` is still emitted (non-breaking) but ' +
811
- 'scheduled for removal — see road-to-tier-removal.',
803
+ 'Integer command `tier` (0/1/2) was a back-compat alias for ' +
804
+ 'the named `visibility` field (ADR-090). REMOVED in manifest ' +
805
+ 'v3 — command artefacts no longer carry `tier`. Read ' +
806
+ '`visibility` (`visible` / `advanced` / `internal`); the old ' +
807
+ 'mapping was 0↔visible, 1↔advanced, 2↔internal.',
812
808
  },
813
809
  ],
814
810
  generated_at: _nowUtc(),
@@ -43,9 +43,16 @@
43
43
  *
44
44
  * So the base is resolved, in order: an explicit `--base`, then the base of the
45
45
  * OPEN PR for this branch as the forge reports it, then the repo's default
46
- * branch from `origin/HEAD`, then `main`. Every message names the base AND
47
- * where it came from, because "I checked main because that is the PR's base"
48
- * and "I checked main because I could not ask" must never print the same.
46
+ * branch AS THE SERVER REPORTS IT (`ls-remote --symref`), then the local
47
+ * `origin/HEAD`, then `main`. Every message names the base AND where it came
48
+ * from, because "I checked main because that is the PR's base" and "I checked
49
+ * main because I could not ask" must never print the same.
50
+ *
51
+ * The server symref matters for the same reason `remoteHead` exists: the local
52
+ * `refs/remotes/origin/HEAD` is written at clone time and never refreshed, so
53
+ * taking the base NAME from it would reintroduce the stale-local-state failure
54
+ * this module argues against everywhere else. It is kept only as the labelled
55
+ * fallback for when the server cannot be reached.
49
56
  *
50
57
  * When the forge cannot be asked (no `gh`, not authenticated, no GitHub remote)
51
58
  * the run says so out loud even under `--quiet`: it has verified the default
@@ -66,12 +73,25 @@ const DEFAULT_BASE = "main";
66
73
  const SELF_TEST_MIN_CASES = 5;
67
74
  const SELF_TEST_MIN_REJECT = 2;
68
75
  const SCRIPT_REL = "src/scripts/check_branch_freshness.ts";
69
- /** One forge round trip on the push path — long enough for a cold `gh`, short enough not to stall a push. */
70
- const FORGE_TIMEOUT_MS = 10_000;
76
+ /**
77
+ * One network round trip on the push path — long enough for a cold `gh`, short
78
+ * enough that the worst case stays under budget.
79
+ *
80
+ * It bounds `git` as well as `gh`, and that is the point rather than tidiness:
81
+ * with a timeout on `gh` only, the two `ls-remote` calls could hang unbounded
82
+ * against an unresponsive SSH remote, so the stated worst case was not one. The
83
+ * slowest path makes three calls (forge, symref, base head), so the arithmetic
84
+ * that keeps this under the 25s `pre_push_budget_seconds` ceiling is 3 x 8s.
85
+ */
86
+ const NETWORK_TIMEOUT_MS = 8_000;
71
87
 
72
88
  function git(args: string[]): string | null {
73
89
  try {
74
- return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
90
+ return execFileSync("git", args, {
91
+ encoding: "utf8",
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ timeout: NETWORK_TIMEOUT_MS,
94
+ }).trim();
75
95
  } catch {
76
96
  return null;
77
97
  }
@@ -89,12 +109,17 @@ export type GhRunner = (args: string[]) => GhResult;
89
109
  * green it did not earn.
90
110
  */
91
111
  export type ForgeAnswer =
92
- | { kind: "pr"; base: string; number: number }
112
+ | { kind: "pr"; base: string; number?: number }
93
113
  | { kind: "none" }
94
114
  | { kind: "unavailable"; reason: string };
95
115
 
96
116
  /** Where the base name came from — printed with every verdict. */
97
- export type BaseSource = "flag" | "pull-request" | "origin-head" | "fallback";
117
+ export type BaseSource =
118
+ | "flag"
119
+ | "pull-request"
120
+ | "origin-symref"
121
+ | "origin-head"
122
+ | "fallback";
98
123
 
99
124
  export interface BaseResolution {
100
125
  readonly base: string;
@@ -109,7 +134,7 @@ function gh(args: string[]): GhResult {
109
134
  const out = execFileSync("gh", args, {
110
135
  encoding: "utf8",
111
136
  stdio: ["ignore", "pipe", "ignore"],
112
- timeout: FORGE_TIMEOUT_MS,
137
+ timeout: NETWORK_TIMEOUT_MS,
113
138
  });
114
139
  return { ok: true, stdout: out.toString() };
115
140
  } catch (err) {
@@ -118,7 +143,7 @@ function gh(args: string[]): GhResult {
118
143
  return { ok: false, reason: "the gh CLI is not installed" };
119
144
  }
120
145
  if (code === "ETIMEDOUT") {
121
- return { ok: false, reason: `gh did not answer within ${FORGE_TIMEOUT_MS / 1000}s` };
146
+ return { ok: false, reason: `gh did not answer within ${NETWORK_TIMEOUT_MS / 1000}s` };
122
147
  }
123
148
  return { ok: false, reason: "gh could not answer — not authenticated, or no GitHub remote" };
124
149
  }
@@ -153,11 +178,29 @@ export function askForgeForBase(branch: string, run: GhRunner = gh): ForgeAnswer
153
178
  if (base === "") {
154
179
  return { kind: "unavailable", reason: "gh answered without a baseRefName" };
155
180
  }
156
- return { kind: "pr", base, number: typeof row.number === "number" ? row.number : 0 };
181
+ // The number is cosmetic — it names the PR in the verdict line — while the
182
+ // base is the answer the gate exists to get. An unusable number therefore
183
+ // does NOT discard the base: doing that made the gate check the repo default
184
+ // instead and warn "could not ask the forge" when it had asked and been
185
+ // answered, trading a correct verdict for a tidier message. `describeBase`
186
+ // omits the number rather than rendering the impossible `#0`.
187
+ const usable =
188
+ typeof row.number === "number" && Number.isInteger(row.number) && row.number > 0;
189
+ return usable ? { kind: "pr", base, number: row.number as number } : { kind: "pr", base };
157
190
  }
158
191
 
159
- /** The repo's own default branch, so a consumer on `master` is not told about `main`. */
160
- export function repoDefaultBase(): { base: string; source: "origin-head" | "fallback" } {
192
+ /**
193
+ * The default branch as the LOCAL clone believes it — no network.
194
+ *
195
+ * `refs/remotes/origin/HEAD` is written at clone time and never refreshed
196
+ * without `git remote set-head`, so this is exactly the class of stale local
197
+ * state this module argues against everywhere else. It has exactly one caller,
198
+ * `serverDefaultBase`, and is reached only when the server could not be asked —
199
+ * where `describeBase` labels it as possibly stale. It decides no verdict on
200
+ * its own; a short-circuit that let it do so is the regression documented in
201
+ * `resolveBase`.
202
+ */
203
+ export function localDefaultBase(): { base: string; source: "origin-head" | "fallback" } {
161
204
  const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
162
205
  if (ref !== null && ref.startsWith("origin/")) {
163
206
  const name = ref.slice("origin/".length).trim();
@@ -168,24 +211,90 @@ export function repoDefaultBase(): { base: string; source: "origin-head" | "fall
168
211
  return { base: DEFAULT_BASE, source: "fallback" };
169
212
  }
170
213
 
214
+ /**
215
+ * The default branch as the SERVER reports it right now.
216
+ *
217
+ * Same discipline as `remoteHead`: a gate whose thesis is "ask the remote"
218
+ * must not take the branch NAME from a tracking ref either. Costs one
219
+ * `ls-remote`, the round trip this gate already pays, and degrades to the
220
+ * local ref (labelled as such) when the server cannot be reached.
221
+ */
222
+ export function serverDefaultBase(): {
223
+ base: string;
224
+ source: "origin-symref" | "origin-head" | "fallback";
225
+ } {
226
+ const out = git(["ls-remote", "--symref", "origin", "HEAD"]);
227
+ if (out !== null) {
228
+ for (const line of out.split("\n")) {
229
+ const trimmed = line.trim();
230
+ if (trimmed.startsWith("ref:")) {
231
+ const name = trimmed.slice("ref:".length).trim().split(/\s+/)[0] ?? "";
232
+ if (name.startsWith("refs/heads/")) {
233
+ const short = name.slice("refs/heads/".length).trim();
234
+ if (short !== "") {
235
+ return { base: short, source: "origin-symref" };
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ return localDefaultBase();
242
+ }
243
+
244
+ /**
245
+ * The base requested on the command line, or null when none was.
246
+ *
247
+ * Both spellings are accepted. `--base=develop` used to be dropped silently by
248
+ * an `indexOf("--base")` lookup, and a dropped request is worse than a rejected
249
+ * one here: the gate then auto-resolves some OTHER base and reports a green
250
+ * about a base the caller never asked about.
251
+ */
252
+ export function explicitBase(argv: readonly string[]): string | null {
253
+ const joined = argv.find((a) => a.startsWith("--base="));
254
+ if (joined !== undefined) {
255
+ const value = joined.slice("--base=".length);
256
+ return value === "" ? null : value;
257
+ }
258
+ const flag = argv.indexOf("--base");
259
+ if (flag < 0) {
260
+ return null;
261
+ }
262
+ const explicit = argv[flag + 1];
263
+ // A bare trailing `--base`, or one followed by another flag, is a typo rather
264
+ // than a base name. Accepting it produced `remoteHead("--quiet")`, which fails
265
+ // and lands on the exit-0 NOT-VERIFIED path — a typo reading as a pass.
266
+ if (explicit === undefined || explicit === "" || explicit.startsWith("-")) {
267
+ return null;
268
+ }
269
+ return explicit;
270
+ }
271
+
171
272
  /** `--base` wins, then the open PR's base, then the repo default. */
172
273
  export function resolveBase(
173
274
  argv: readonly string[],
174
275
  branch: string,
175
276
  forge: (b: string) => ForgeAnswer = (b) => askForgeForBase(b),
176
277
  ): BaseResolution {
177
- const flag = argv.indexOf("--base");
178
- if (flag >= 0) {
179
- const explicit = argv[flag + 1];
180
- if (explicit !== undefined && explicit !== "") {
181
- return { base: explicit, source: "flag" };
182
- }
278
+ const explicit = explicitBase(argv);
279
+ if (explicit !== null) {
280
+ return { base: explicit, source: "flag" };
183
281
  }
282
+ // There is deliberately no cheap short-circuit for "the branch looks like the
283
+ // default". It was tried and reverted: it decided a VERDICT from
284
+ // `localDefaultBase`, the stale clone-time ref this module argues against, so
285
+ // a branch whose name collided with a stale or fallback local default was
286
+ // skipped without any check at all. The premise it rested on — that
287
+ // `gh pr list --head main` can never usefully answer — is false: a PR from the
288
+ // default branch into a release line or a parent PR is exactly that answer,
289
+ // and it is the case this gate exists for. Standing on the real base is
290
+ // short-circuited one layer up, in `main`, AFTER the base is known.
184
291
  const answer = forge(branch);
185
292
  if (answer.kind === "pr") {
186
- return { base: answer.base, source: "pull-request", pr: answer.number };
293
+ return answer.number === undefined
294
+ ? { base: answer.base, source: "pull-request" }
295
+ : { base: answer.base, source: "pull-request", pr: answer.number };
187
296
  }
188
- const fallback = repoDefaultBase();
297
+ const fallback = serverDefaultBase();
189
298
  return answer.kind === "unavailable"
190
299
  ? { base: fallback.base, source: fallback.source, unverified: answer.reason }
191
300
  : { base: fallback.base, source: fallback.source };
@@ -197,9 +306,13 @@ export function describeBase(r: BaseResolution): string {
197
306
  case "flag":
198
307
  return "base given on the command line";
199
308
  case "pull-request":
200
- return `base of open PR #${r.pr ?? 0}`;
309
+ return r.pr === undefined
310
+ ? "base of the open PR for this branch"
311
+ : `base of open PR #${r.pr}`;
312
+ case "origin-symref":
313
+ return "repo default branch, as the server reports it";
201
314
  case "origin-head":
202
- return "repo default branch (origin/HEAD)";
315
+ return "repo default branch from the LOCAL origin/HEAD ref, which may be stale";
203
316
  case "fallback":
204
317
  return `repo default (assumed ${DEFAULT_BASE} — origin/HEAD is not set)`;
205
318
  }
@@ -243,7 +356,15 @@ export function containsCommit(sha: string): boolean | null {
243
356
  * gate reading the tracking ref would pass it — which is exactly the false green
244
357
  * this gate exists to remove.
245
358
  */
246
- function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
359
+ interface Scaffold {
360
+ origin: string;
361
+ work: string;
362
+ g: (args: string[], at: string) => void;
363
+ write: (at: string, name: string, body: string) => void;
364
+ }
365
+
366
+ /** The bare origin + working clone every fixture below starts from. */
367
+ function scaffold(root: string): Scaffold {
247
368
  const origin = path.join(root, "origin.git");
248
369
  const work = path.join(root, "work");
249
370
  fs.mkdirSync(origin, { recursive: true });
@@ -260,6 +381,11 @@ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
260
381
  g(["remote", "add", "origin", origin], work);
261
382
  write(work, "a.txt", "one");
262
383
  g(["push", "-u", "origin", DEFAULT_BASE], work);
384
+ return { origin, work, g, write };
385
+ }
386
+
387
+ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
388
+ const { origin, work, g, write } = scaffold(root);
263
389
  g(["checkout", "-b", "feat/x"], work);
264
390
  write(work, "b.txt", "two");
265
391
  if (opts.advanceRemote) {
@@ -272,37 +398,31 @@ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
272
398
  }
273
399
 
274
400
  /**
275
- * A repo where the branch's real base is NOT the default branch: `feat/x` is
276
- * cut from `develop`, `develop` then advances on the remote, and `main` never
277
- * moves.
401
+ * A repo where the branch's real base is NOT `main`: `feat/x` is cut from
402
+ * `develop`, `develop` then advances on the remote, and `main` never moves.
403
+ * The SERVER's `HEAD` is pointed at `serverDefault`.
278
404
  *
279
- * This is the fixture that demonstrates the defect the base resolution exists
280
- * to remove. Checked against `main` the branch is perfectly current — a GREEN
281
- * that is true and irrelevant. Checked against `develop`, the base it will
282
- * actually merge into, it is behind. Both verdicts come from the same tree, so
283
- * a regression that reinstates a hardcoded base flips exactly one of them.
405
+ * The base is left for the gate to resolve — no `--base`. That is the whole
406
+ * point of the fixture, and the first version got it wrong: it passed
407
+ * `--base` on both cases, which exercises the flag branch that existed before
408
+ * this change. Deleting the entire forge/symref resolution would have left both
409
+ * cases green, so the pair proved nothing about the new behaviour and the raised
410
+ * self-test floors rested on coverage that did not exist. (R2 finding 2.)
411
+ *
412
+ * With `serverDefault = "develop"` the gate must refuse: that is the base this
413
+ * branch is actually behind. With `serverDefault = "main"` the same tree passes.
414
+ * A regression that reinstates a hardcoded `main` flips the first case, because
415
+ * nothing but the resolution decides which base is asked about.
284
416
  */
285
- function buildTwoBaseFixture(root: string): string {
286
- const origin = path.join(root, "origin.git");
287
- const work = path.join(root, "work");
288
- fs.mkdirSync(origin, { recursive: true });
289
- const g = (args: string[], at: string): void => {
290
- execFileSync("git", args, { cwd: at, stdio: "ignore" });
291
- };
292
- const write = (at: string, name: string, body: string): void => {
293
- fs.writeFileSync(path.join(at, name), body);
294
- g(["add", "-A"], at);
295
- g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", name], at);
296
- };
297
- g(["init", "--bare", "-b", DEFAULT_BASE], origin);
298
- g(["init", "-b", DEFAULT_BASE, work], root);
299
- g(["remote", "add", "origin", origin], work);
300
- write(work, "a.txt", "one");
301
- g(["push", "-u", "origin", DEFAULT_BASE], work);
417
+ function buildTwoBaseFixture(root: string, serverDefault: string): string {
418
+ const { origin, work, g, write } = scaffold(root);
302
419
  g(["checkout", "-b", "develop"], work);
303
420
  g(["push", "-u", "origin", "develop"], work);
304
421
  g(["checkout", "-b", "feat/x"], work);
305
422
  write(work, "b.txt", "two");
423
+ // The bare repo's own HEAD is what `ls-remote --symref origin HEAD` reports,
424
+ // so this is the server-side default branch, not a local tracking ref.
425
+ g(["symbolic-ref", "HEAD", `refs/heads/${serverDefault}`], origin);
306
426
  // A second clone advances `develop` only — `main` stays exactly where it was.
307
427
  const other = path.join(root, "other");
308
428
  g(["clone", origin, other], root);
@@ -312,6 +432,23 @@ function buildTwoBaseFixture(root: string): string {
312
432
  return work;
313
433
  }
314
434
 
435
+ /**
436
+ * Strip everything that could point the child's `gh` at a real forge.
437
+ *
438
+ * `runGateCli` spawns with the full inherited environment, and since the
439
+ * two-base cases dropped `--base` the resolution itself is what they assert on.
440
+ * An ambient `GH_REPO` can make `gh pr list --head feat/x` answer about a real
441
+ * PR in someone else's repository and silently move the base under test — the
442
+ * same ambient-credential hazard the `forge` seam closed for the unit tests,
443
+ * which would otherwise have stayed live in the only coverage of the new
444
+ * symref resolution. `CI` would no-op every case green.
445
+ */
446
+ function isolateChildEnv(): void {
447
+ for (const key of ["CI", "GITHUB_ACTIONS", "GH_REPO", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GH_CONFIG_DIR"]) {
448
+ delete process.env[key];
449
+ }
450
+ }
451
+
315
452
  export function selfTest(repoRoot: string): number {
316
453
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbf-selftest-"));
317
454
  const run = (advanceRemote: boolean, onBase: boolean): number => {
@@ -320,17 +457,15 @@ export function selfTest(repoRoot: string): number {
320
457
  if (onBase) {
321
458
  execFileSync("git", ["checkout", DEFAULT_BASE], { cwd: work, stdio: "ignore" });
322
459
  }
323
- // The child must not inherit a CI flag, or every case would no-op green.
324
- delete process.env["CI"];
325
- delete process.env["GITHUB_ACTIONS"];
460
+ isolateChildEnv();
326
461
  return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
327
462
  };
328
- const runTwoBase = (base: string): number => {
463
+ const runTwoBase = (serverDefault: string): number => {
329
464
  const dir = fs.mkdtempSync(path.join(root, "twobase-"));
330
- const work = buildTwoBaseFixture(dir);
331
- delete process.env["CI"];
332
- delete process.env["GITHUB_ACTIONS"];
333
- return runGateCli(repoRoot, SCRIPT_REL, ["--quiet", "--base", base], work);
465
+ const work = buildTwoBaseFixture(dir, serverDefault);
466
+ isolateChildEnv();
467
+ // No `--base`: the gate resolves it, which is the behaviour under test.
468
+ return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
334
469
  };
335
470
  const cases: SelfTestCase[] = [
336
471
  {
@@ -339,12 +474,12 @@ export function selfTest(repoRoot: string): number {
339
474
  run: () => run(true, false),
340
475
  },
341
476
  {
342
- name: "a branch behind its REAL base (develop) is refused when that base is checked",
477
+ name: "the RESOLVED base decides: server default develop, branch behind it, REFUSED",
343
478
  expect: "reject",
344
479
  run: () => runTwoBase("develop"),
345
480
  },
346
481
  {
347
- name: "the same branch passes against main — the false green a hardcoded base produces",
482
+ name: "same tree, server default main — passes, so the resolution is what moved the verdict",
348
483
  expect: "accept",
349
484
  run: () => runTwoBase(DEFAULT_BASE),
350
485
  },
@@ -389,7 +524,19 @@ function scanReport(scanned: number, allowEmpty?: string): void {
389
524
  });
390
525
  }
391
526
 
392
- export function main(argv: string[] = process.argv.slice(2)): number {
527
+ /**
528
+ * @param forge Seam for the forge lookup. Production passes nothing and gets the
529
+ * real `gh`. Tests MUST pass a stub: without this parameter the only way to
530
+ * reach the forge-unavailable branch was to not have `gh` installed, so the test
531
+ * for it could only assert conditionally — i.e. it passed whether or not the
532
+ * warning was emitted. It also stopped every fixture test from spawning `gh`
533
+ * against ambient credentials, where a `GH_REPO` override could return a real
534
+ * PR base for a same-named branch and silently change the base under test.
535
+ */
536
+ export function main(
537
+ argv: string[] = process.argv.slice(2),
538
+ forge?: (b: string) => ForgeAnswer,
539
+ ): number {
393
540
  if (argv.includes("--self-test")) {
394
541
  return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."));
395
542
  }
@@ -408,7 +555,9 @@ export function main(argv: string[] = process.argv.slice(2)): number {
408
555
  }
409
556
 
410
557
  // Resolved AFTER the branch is known: the PR's base is a fact about THIS branch.
411
- const resolved = resolveBase(argv, branch);
558
+ // `resolveBase`'s third parameter has a default, so passing `undefined`
559
+ // positionally already selects the real `gh` — no branch needed here.
560
+ const resolved = resolveBase(argv, branch, forge);
412
561
  const base = resolved.base;
413
562
 
414
563
  if (branch === base) {
@@ -247,19 +247,19 @@ export function main(): number {
247
247
  out.push('');
248
248
 
249
249
  out.push('## Sample commands (first 10)\n');
250
- out.push('| File | name | tier | cluster |');
250
+ out.push('| File | name | visibility | cluster |');
251
251
  out.push('|---|---|---|---|');
252
252
  for (const c of commands.slice(0, 10)) {
253
253
  const fm = _frontmatter(c);
254
254
  const rel = _relativeTo(c, SRC);
255
- out.push(`| \`${rel}\` | ${fm.name ?? ''} | ${fm.tier ?? ''} | ${fm.cluster ?? ''} |`);
255
+ out.push(`| \`${rel}\` | ${fm.name ?? ''} | ${fm.visibility ?? ''} | ${fm.cluster ?? ''} |`);
256
256
  }
257
257
  out.push('');
258
258
 
259
259
  out.push('## Notes\n');
260
260
  out.push('- Every skill carries `domain:` today — Phase 4 mapping uses it as primary signal.');
261
261
  out.push('- Rules use `type` (always | auto) + `tier` (safety-floor | kernel | …); no `domain:` today.');
262
- out.push('- Commands use `tier` + `cluster`; no `domain:` today.');
262
+ out.push('- Commands use `visibility` + `cluster`; no `domain:` today.');
263
263
  out.push('- Templates have no consistent frontmatter; Phase 4 quarantines them in `unassigned-artefacts.yml`.');
264
264
  out.push('');
265
265
 
@@ -590,6 +590,26 @@ concerns:
590
590
  # blocker: in-process Agent-tool subagents inherit the host process env and
591
591
  # cannot be marked per-spawn; the axis binds where the suite launches a CLI
592
592
  # session itself.
593
+ #
594
+ # Superseded in part, 2026-08-14 (road-to-subagent-lifecycle-integrity Phase 4
595
+ # Step 2). The env half above still stands — nothing marks an in-process spawn
596
+ # per-process. What has changed is that env is no longer the only candidate
597
+ # channel: `subagent_start` / `subagent_stop` are bound on this host
598
+ # (2.1.229, Phase 0 Step 1) and their payload carries `agent_id`, which
599
+ # `subagent-ledger` correlates on today. So a payload-bound role axis —
600
+ # `agent_id` present ⇒ role `worker` — is a live proposal (Phase 4 Step 1),
601
+ # not a closed question.
602
+ # It is NOT implemented, and the reason is a missing measurement rather than a
603
+ # missing decision: whether `agent_id` reaches a `PreToolUse` / `PostToolUse`
604
+ # payload inside a subagent is unverified on this host. That is Phase 0 Step 4,
605
+ # blocked on `raw-capture-needs-host-env` (the capture itself is already
606
+ # shipped — `dispatch_hook._maybe_capture_payload`). A neighbouring assertion
607
+ # from the same spike already came back negative: `agent_type` does NOT arrive
608
+ # on `SubagentStop` (18 of 25 stop records null), so the documented-field
609
+ # presence is not assumed for `agent_id` either.
610
+ # Until that spike lands the env path is the only one wired, and the
611
+ # pre_tool_use exemption above is unaffected either way — a payload-resolved
612
+ # `worker` still cannot drop a pre_tool_use concern.
593
613
  roles:
594
614
  worker:
595
615
  # Orchestrator-only concerns (Phase 2.3): a worker never talks to the
@@ -73,8 +73,9 @@
73
73
  * a `subagent_reaped` line is written for it. That line is not bookkeeping —
74
74
  * it is the closest thing this instrument has to a direct observation of a
75
75
  * never-returning dispatch, which is why the leak is reported rather than
76
- * quietly swept. `session_id` rides on the record so a future sweep can scope
77
- * by session; nothing reads it yet and it is not claimed to.
76
+ * quietly swept. `session_id` rides on the open record AND on both dispatch
77
+ * lines so a rate can be scoped by session; nothing reads it yet and it is not
78
+ * claimed to.
78
79
  *
79
80
  * ── Depth and concurrency (Step 3) ────────────────────────────────────────
80
81
  *
@@ -598,6 +599,13 @@ function handleStart(root: string, payload: JsonObject, nowIso: string, sessionI
598
599
  event: 'subagent_start',
599
600
  ts: nowIso,
600
601
  ref,
602
+ // Phase 1 Step 4 correction (b): without this the dispatch denominator
603
+ // is not per-session. Three sessions share one ledger file, so a rate
604
+ // computed over the file aggregates strangers — the measured window
605
+ // held 7 starts against 25 stops, one of them an agent type this
606
+ // session never dispatched. The open record already carried it; the
607
+ // appended line did not, and the line is what a rate is computed from.
608
+ session_id: sessionId,
601
609
  agent_type: rec.agent_type,
602
610
  parent_ref: parentRef,
603
611
  depth,
@@ -606,7 +614,7 @@ function handleStart(root: string, payload: JsonObject, nowIso: string, sessionI
606
614
  });
607
615
  }
608
616
 
609
- function handleStop(root: string, payload: JsonObject, nowIso: string): void {
617
+ function handleStop(root: string, payload: JsonObject, nowIso: string, sessionId: string | null): void {
610
618
  const agentId = str(payload, 'agent_id', 'agentId');
611
619
  if (agentId === null) {
612
620
  appendUnidentified(root, 'subagent_stop', nowIso, payload);
@@ -631,6 +639,11 @@ function handleStop(root: string, payload: JsonObject, nowIso: string): void {
631
639
  event: 'subagent_stop',
632
640
  ts: nowIso,
633
641
  ref,
642
+ // The session this stop was OBSERVED in, read from the same envelope
643
+ // position as the start's — never back-filled from `rec`, which would
644
+ // relabel a cross-session stop as belonging to the dispatching session
645
+ // and hide exactly the mismatch this field exists to expose.
646
+ session_id: sessionId,
634
647
  agent_type: rec?.agent_type ?? str(payload, 'agent_type', 'agentType', 'subagent_type', 'subagentType'),
635
648
  depth: rec?.depth ?? null,
636
649
  depth_basis: rec?.depth_basis ?? null,
@@ -661,7 +674,7 @@ export function processEnvelope(envelope: JsonValue, consumerRoot: string): numb
661
674
  reapStaleOpenRecords(consumerRoot, nowIso);
662
675
 
663
676
  if (event === 'subagent_start') handleStart(consumerRoot, payload, nowIso, sessionId);
664
- else handleStop(consumerRoot, payload, nowIso);
677
+ else handleStop(consumerRoot, payload, nowIso, sessionId);
665
678
  } catch {
666
679
  // Malformed payload, unreadable disk, anything — never disturb the run.
667
680
  return EXIT_ALLOW;