@event4u/agent-config 9.25.0 → 9.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +58 -143
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +21 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +256 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/active-remediation.md +2 -0
  10. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  11. package/dist/agent-src/rules/architecture.md +2 -0
  12. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  13. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  14. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  15. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  16. package/dist/agent-src/rules/brand-consistency.md +2 -0
  17. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  18. package/dist/agent-src/rules/broken-access-control.md +2 -0
  19. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  20. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  21. package/dist/agent-src/rules/code-provenance.md +2 -0
  22. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  23. package/dist/agent-src/rules/commit-conventions.md +2 -0
  24. package/dist/agent-src/rules/communication-through-line.md +2 -0
  25. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  26. package/dist/agent-src/rules/context-hygiene.md +10 -0
  27. package/dist/agent-src/rules/copilot-routing.md +2 -0
  28. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  29. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  30. package/dist/agent-src/rules/delegation-policy.md +5 -2
  31. package/dist/agent-src/rules/design-fidelity.md +19 -1
  32. package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
  33. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  34. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  35. package/dist/agent-src/rules/docker-commands.md +2 -0
  36. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  37. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  38. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  39. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  40. package/dist/agent-src/rules/downstream-changes.md +2 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +19 -3
  43. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  44. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  45. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  46. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  47. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  48. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  49. package/dist/agent-src/rules/guidelines.md +2 -0
  50. package/dist/agent-src/rules/history-discipline.md +2 -0
  51. package/dist/agent-src/rules/icon-consistency.md +2 -0
  52. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  53. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  54. package/dist/agent-src/rules/invite-challenge.md +2 -0
  55. package/dist/agent-src/rules/laravel-routing.md +2 -0
  56. package/dist/agent-src/rules/laravel-translations.md +2 -0
  57. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  58. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  59. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  60. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  61. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  62. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  63. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  64. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  65. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  66. package/dist/agent-src/rules/model-recommendation.md +2 -0
  67. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  68. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  69. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  70. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  71. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  72. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  73. package/dist/agent-src/rules/output-discipline.md +2 -0
  74. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  75. package/dist/agent-src/rules/persona-governance.md +2 -0
  76. package/dist/agent-src/rules/php-coding.md +2 -0
  77. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  78. package/dist/agent-src/rules/preservation-guard.md +2 -0
  79. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  80. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  81. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  82. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  83. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  84. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  85. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  86. package/dist/agent-src/rules/runtime-safety.md +2 -0
  87. package/dist/agent-src/rules/scale-discipline.md +2 -0
  88. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  89. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  90. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  91. package/dist/agent-src/rules/session-canary.md +63 -19
  92. package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
  93. package/dist/agent-src/rules/size-enforcement.md +2 -0
  94. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  95. package/dist/agent-src/rules/skill-quality.md +2 -0
  96. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  97. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  98. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  99. package/dist/agent-src/rules/source-of-truth.md +2 -0
  100. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  101. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  102. package/dist/agent-src/rules/symfony-routing.md +2 -0
  103. package/dist/agent-src/rules/think-before-action.md +2 -0
  104. package/dist/agent-src/rules/token-budget-discipline.md +8 -3
  105. package/dist/agent-src/rules/token-efficiency.md +2 -0
  106. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  107. package/dist/agent-src/rules/tool-safety.md +2 -0
  108. package/dist/agent-src/rules/ui-audit-gate.md +7 -0
  109. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  110. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  111. package/dist/agent-src/rules/user-interaction.md +5 -2
  112. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  113. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  114. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  115. package/dist/cli/registry.js +2 -0
  116. package/dist/cli/registry.js.map +1 -1
  117. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  118. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  119. package/dist/cli-delegate/cmd_conformance.js +1 -1
  120. package/dist/cli-delegate/cmd_doctor.js +1 -1
  121. package/dist/cli-delegate/cmd_export.js +1 -1
  122. package/dist/cli-delegate/cmd_prune.js +1 -1
  123. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  124. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  125. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  126. package/dist/cli-delegate/cmd_sync.js +1 -1
  127. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  128. package/dist/cli-delegate/cmd_validate.js +1 -1
  129. package/dist/discovery/deprecation-report.md +1 -1
  130. package/dist/discovery/discovery-manifest.json +191 -120
  131. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  132. package/dist/discovery/discovery-manifest.summary.md +4 -4
  133. package/dist/discovery/orphan-report.md +1 -1
  134. package/dist/discovery/packs.json +11 -8
  135. package/dist/discovery/trust-report.md +3 -3
  136. package/dist/discovery/workspaces.json +9 -6
  137. package/dist/hooks/dispatch.js +1148 -349
  138. package/dist/install/install.mjs +1 -1
  139. package/dist/mcp/registry-manifest.json +3 -3
  140. package/dist/mcp/server.json +2 -2
  141. package/dist/mcp/server.mjs +571 -417
  142. package/dist/router.json +1 -1
  143. package/dist/scripts/condense.js +33 -2
  144. package/dist/scripts/condense.js.map +1 -1
  145. package/dist/server/io/yamlIO.js +85 -0
  146. package/dist/server/io/yamlIO.js.map +1 -1
  147. package/dist/server/nicknameResolver.js +116 -0
  148. package/dist/server/nicknameResolver.js.map +1 -0
  149. package/dist/server/routes/ping.js +29 -11
  150. package/dist/server/routes/ping.js.map +1 -1
  151. package/dist/server/routes/settings.js +1 -12
  152. package/dist/server/routes/settings.js.map +1 -1
  153. package/dist/server/routes/wizard.js +52 -2
  154. package/dist/server/routes/wizard.js.map +1 -1
  155. package/dist/shared/interactiveContext.js +73 -0
  156. package/dist/shared/interactiveContext.js.map +1 -0
  157. package/dist/shared/nicknamePrefill.js +59 -0
  158. package/dist/shared/nicknamePrefill.js.map +1 -0
  159. package/dist/shared/settingsAsks.js +128 -0
  160. package/dist/shared/settingsAsks.js.map +1 -0
  161. package/dist/shared/settingsCarveOut.js +118 -0
  162. package/dist/shared/settingsCarveOut.js.map +1 -0
  163. package/dist/shared/settingsConsent.js +93 -0
  164. package/dist/shared/settingsConsent.js.map +1 -0
  165. package/dist/shared/settingsSurface.js +7 -1
  166. package/dist/shared/settingsSurface.js.map +1 -1
  167. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  168. package/package.json +1 -1
  169. package/src/config/agents-paths.yml +15 -0
  170. package/src/config/evaluator-budgets.json +4 -4
  171. package/src/config/gate-coverage.yml +29 -1
  172. package/src/config/gate-violation-baselines.json +13 -7
  173. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  174. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  175. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  176. package/src/scripts/_dispatch.bash +18 -0
  177. package/src/scripts/_lib/git_common_dir.ts +128 -0
  178. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  179. package/src/scripts/_lib/session_register.ts +361 -0
  180. package/src/scripts/ai-video/stitch.sh +27 -9
  181. package/src/scripts/before_complete_hook.ts +23 -4
  182. package/src/scripts/bench_ab_v2_run.ts +272 -16
  183. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  184. package/src/scripts/check_completion_review.ts +80 -3
  185. package/src/scripts/check_corpus_staleness.ts +692 -0
  186. package/src/scripts/check_enforcement_coverage.ts +192 -7
  187. package/src/scripts/check_gate_coverage.ts +67 -0
  188. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  189. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  190. package/src/scripts/condense.ts +36 -2
  191. package/src/scripts/conformance_scan.ts +82 -17
  192. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  193. package/src/scripts/generate_settings_reference.ts +232 -0
  194. package/src/scripts/git_authorization_hook.ts +75 -10
  195. package/src/scripts/hook_manifest.yaml +87 -27
  196. package/src/scripts/hooks/block_no_verify.ts +59 -2
  197. package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
  198. package/src/scripts/hooks/concern_registry.ts +2 -0
  199. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  200. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  201. package/src/scripts/hooks/evidence_independence.ts +40 -6
  202. package/src/scripts/language_mirror_hook.ts +256 -17
  203. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  204. package/src/scripts/lint_agents_layout.ts +2 -0
  205. package/src/scripts/lint_hook_manifest.ts +29 -0
  206. package/src/scripts/memory_learn_hook.ts +109 -6
  207. package/src/scripts/orchestration_backfill.ts +354 -0
  208. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  209. package/src/scripts/release.ts +180 -37
  210. package/src/scripts/schemas/review-findings.schema.json +99 -0
  211. package/src/scripts/schemas/rule.schema.json +5 -0
  212. package/src/scripts/session_canary_hook.ts +35 -3
  213. package/src/scripts/session_register_hook.ts +288 -0
  214. package/src/scripts/sessions_cli.ts +140 -0
  215. package/src/scripts/validate_frontmatter.ts +68 -0
  216. package/src/server/io/yamlIO.ts +87 -0
  217. package/src/shared/interactiveContext.ts +98 -0
  218. package/src/shared/nicknamePrefill.ts +96 -0
  219. package/src/shared/settingsAsks.ts +176 -0
  220. package/src/shared/settingsCarveOut.ts +143 -0
  221. package/src/shared/settingsConsent.ts +136 -0
  222. package/src/shared/settingsSurface.ts +7 -1
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Shared per-session register — so a starting session can see that another
3
+ * session is already live on this repo, on which branch, and on which roadmap.
4
+ *
5
+ * ## Where it lives, and why that needs no new machinery
6
+ *
7
+ * `<git-common-dir>/agent-sessions/<session_id>.json`. The common git directory
8
+ * resolves to the **same** directory from every worktree of a repo, so the
9
+ * register is shared by construction: no new directory to create, no sync step,
10
+ * no generator, and never tracked by git. Resolution is
11
+ * `_lib/git_common_dir.ts` — reused, not re-implemented, because a repo with two
12
+ * different answers to "where is the common dir" is a bug waiting for a
13
+ * symlinked parent.
14
+ *
15
+ * Runtime state under `agents/runtime/state/` cannot serve this purpose:
16
+ * concerns run with `CWD = envelope.workspace_root`, so in a worktree that path
17
+ * is the worktree's own. Measured 2026-08-07 — the main checkout's state
18
+ * directory was populated and a fresh worktree's did not exist at all. Two
19
+ * sessions in two worktrees share nothing there.
20
+ *
21
+ * ## One file per session — one writer each
22
+ *
23
+ * Each session owns exactly one file and is its only writer, so there is no
24
+ * concurrent-write case to solve: atomicity comes from write-temp + rename
25
+ * (`_lib/fs_atomic.ts`), the file never grows, compaction does not exist, the
26
+ * reader does a `readdir` plus N small reads, and cleanup is an `unlink` of
27
+ * expired files during the `session_start` read.
28
+ *
29
+ * The append-only JSONL alternative buys concurrency safety this layout does not
30
+ * need and pays for it with unbounded growth (one record per turn per session
31
+ * once heartbeating), a rotation problem (rotating under live appenders loses
32
+ * heartbeats to the unlinked inode — the classic logrotate defect), and
33
+ * fold-the-whole-file reads.
34
+ *
35
+ * ## Liveness is a heartbeat inside the record — never file mtime
36
+ *
37
+ * `last_seen` is rewritten every turn by a hook, and expiry compares that field.
38
+ * File mtime is explicitly ruled out: `worktree_cleanup_check.ts:65-68` records
39
+ * the failure — plain `git status` refreshes the on-disk index and bumps the very
40
+ * mtime being read as liveness, which moved 10 worktrees from safe to live
41
+ * between two consecutive runs. A field only this concern writes has no such
42
+ * coupling.
43
+ *
44
+ * ## Two accepted limits — declared, not hidden
45
+ *
46
+ * 1. **Idle is indistinguishable from crashed.** A session left open over lunch
47
+ * stops heartbeating, expires, and releases its claim although the user
48
+ * returns. No hook-based heartbeat can tell that apart from a crash. This is
49
+ * accepted, and it belongs in user-facing documentation: walking away for
50
+ * longer than the TTL means another session may claim your branch.
51
+ * 2. **This is advisory, not a mutex.** Two sessions can claim in the same
52
+ * millisecond. Nothing here provides exclusion, and no later feature may be
53
+ * built on it as if it did.
54
+ *
55
+ * ## Failure is always open
56
+ *
57
+ * Every function here swallows its errors and degrades to "no register": a
58
+ * session that cannot read or write it still starts, and behaves exactly as
59
+ * sessions did before this existed.
60
+ */
61
+
62
+ import fs from 'node:fs';
63
+ import path from 'node:path';
64
+
65
+ import { write_atomic } from './fs_atomic.js';
66
+ import { git_common_dir } from './git_common_dir.js';
67
+
68
+ /** Directory name under the common git dir. Never tracked; git ignores unknown
69
+ * directories inside `.git` by construction. */
70
+ export const REGISTER_DIRNAME = 'agent-sessions';
71
+
72
+ /**
73
+ * Per-host TTL in seconds — how long a record may go without a heartbeat before
74
+ * a reader treats the session as gone.
75
+ *
76
+ * **Derived from data for exactly one host, and honest about the rest.** The
77
+ * chat-history corpus in this repo holds 71 records across 5 sessions over 24.5
78
+ * hours, and every one of them is `claude`; 77 % of the gaps come from a single
79
+ * session. The per-host split the design asked for is **not measurable** from
80
+ * that, so no interpolated values are shipped for hosts nobody observed. Full
81
+ * measurement record: `agents/evidence/analysis/parallel-session-register-phase1.md`.
82
+ *
83
+ * `claude` is taken from the **raw, unfiltered** p99 (13 705 s) and the raw
84
+ * user→user max (14 199 s), rounded up to the next hour. Deliberately *not* the
85
+ * idle-filtered p95: filtering gaps > 30 min characterises turn cadence
86
+ * correctly and is wrong as an expiry basis, because those gaps occur **inside**
87
+ * live sessions — 35 % of user→user gaps exceed 30 minutes while the session is
88
+ * demonstrably still working. A TTL from the filtered p95 would expire a live
89
+ * claim on roughly a third of turns, which is the collision this register exists
90
+ * to prevent, re-created by its own mitigation.
91
+ */
92
+ export const TTL_MEASURED_SECONDS: Readonly<Record<string, number>> = Object.freeze({
93
+ claude: 14_400, // 4 h — raw p99 13 705 s / raw user→user max 14 199 s, rounded up
94
+ });
95
+
96
+ /**
97
+ * TTL for a host with **no** measurements: `3 ×` the one measured p99.
98
+ *
99
+ * The name says `MEASURED` above and `DEFAULT` here on purpose (council
100
+ * 2026-08-07, both members): a map holding one real entry plus a fallback is
101
+ * honest only if it is *named* as that, rather than presented as a populated
102
+ * per-host table. Six empty slots would be speculation wearing a data structure.
103
+ *
104
+ * The council also moved this number down from a proposed 24 h. The argument
105
+ * against 24 h: the evidence for a wide spread is that the same host's raw p99
106
+ * in another project corpus is 53 903 s, i.e. a 4× range — which does not
107
+ * justify a 6× extrapolation, and a full day makes the register useless on an
108
+ * unmeasured host, since a crashed session blocks a roadmap claim until
109
+ * tomorrow. 12 h stays comfortably above every observed live-session gap while
110
+ * keeping the claim recoverable within a working day.
111
+ */
112
+ export const TTL_DEFAULT_SECONDS = 43_200; // 12 h
113
+
114
+ /**
115
+ * Resolve the TTL for a record, from **the record's own platform** — never the
116
+ * reader's. Session B on one host reading session A's record must apply A's
117
+ * cadence, because the TTL describes how often A heartbeats.
118
+ */
119
+ export function ttl_seconds_for(platform: string | null | undefined): number {
120
+ const key = String(platform ?? '').trim().toLowerCase();
121
+ return TTL_MEASURED_SECONDS[key] ?? TTL_DEFAULT_SECONDS;
122
+ }
123
+
124
+ /** True when this platform's TTL is a measured value rather than the fallback. */
125
+ export function ttl_is_measured(platform: string | null | undefined): boolean {
126
+ const key = String(platform ?? '').trim().toLowerCase();
127
+ return Object.prototype.hasOwnProperty.call(TTL_MEASURED_SECONDS, key);
128
+ }
129
+
130
+ /**
131
+ * Platforms whose `stop` slot means **the task really ended**, so the register
132
+ * entry is deleted there instead of merely refreshed.
133
+ *
134
+ * The general rule is the opposite and stays the default: `stop` is a *second
135
+ * heartbeat carrier*, never a deregistration trigger, because on Claude Code the
136
+ * native `Stop` fires after **every assistant reply** — deregistering there
137
+ * would kill a live session's own record after its first reply.
138
+ *
139
+ * Cline is the one host where that reading is wrong. Its `stop` is mapped from
140
+ * `TaskCancel` (`hook_manifest.yaml:473`) and is typed `per-event` rather than
141
+ * `per-turn` (`_lib/obligation_frequency.ts:269-271`), while its `session_end`
142
+ * is mapped from `TaskComplete` (`:472`). A **cancelled** Cline task therefore
143
+ * reaches neither deregistration path and leaks its claim until the TTL — and
144
+ * cancel-and-restart is a routine Cline workflow, not an edge case.
145
+ *
146
+ * **This is an explicit allow-list, not a computed condition** (council
147
+ * 2026-08-07, both members). Deriving it from `slot_frequency(...) ===
148
+ * 'per-event'` would mean a future platform silently acquires deregistration
149
+ * behaviour the moment someone types it that way, in a code path where the
150
+ * failure mode is "a live session deleted its own claim". A human adds a line
151
+ * here, deliberately, or the platform keeps the safe default.
152
+ *
153
+ * The safety model is unchanged either way: TTL remains the correctness basis on
154
+ * every platform including Cline. This only frees the claim sooner.
155
+ */
156
+ export const DEREGISTER_ON_STOP_PLATFORMS: ReadonlySet<string> = new Set(['cline']);
157
+
158
+ /** True when `stop` on this platform means the task ended, not "a reply landed". */
159
+ export function stop_means_session_end(platform: string | null | undefined): boolean {
160
+ return DEREGISTER_ON_STOP_PLATFORMS.has(String(platform ?? '').trim().toLowerCase());
161
+ }
162
+
163
+ /**
164
+ * Platforms where a per-turn heartbeat actually fires today.
165
+ *
166
+ * Deliberately **not** derived from the frequency lattice. That instrument
167
+ * models *slot presence*, and two hosts have a slot that never fires:
168
+ *
169
+ * - `cursor` — `user_prompt_submit` and `stop` are **IDE-only**; the CLI fires
170
+ * only shell-execution hooks (`hook_manifest.yaml:365-366`,
171
+ * `chat-history-platform-hooks.md:214`). The lattice has no IDE/CLI dimension,
172
+ * so it reports cursor covered.
173
+ * - `cowork` — slots are structurally wired but lifecycle events do not fire
174
+ * (`hook_manifest.yaml:346-351`).
175
+ * - `copilot` — no hook surface at all (`hook_manifest.yaml:428-429`), excluded
176
+ * from the join by declaration.
177
+ *
178
+ * A session on an excluded host still *registers* if a `session_start` reaches
179
+ * it, and simply stops being visible after its TTL. It is never reported as
180
+ * covered.
181
+ */
182
+ export const HEARTBEAT_REACHABLE_PLATFORMS: ReadonlySet<string> = new Set([
183
+ 'claude',
184
+ 'augment', // post-reply `stop` only — sufficient for a liveness stamp
185
+ 'cline', // pre-reply `user_prompt_submit` only
186
+ 'windsurf',
187
+ 'gemini',
188
+ ]);
189
+
190
+ /** One live session, as seen by every other session on the repo. */
191
+ export interface SessionRecord {
192
+ /** Host-provided session id; also the filename stem. */
193
+ session_id: string;
194
+ /** Host platform — decides which TTL applies to THIS record. */
195
+ platform: string;
196
+ /** Absolute path of the worktree this session is working in. */
197
+ worktree: string;
198
+ /** Branch at the last heartbeat. Re-read every beat; sessions check out. */
199
+ branch: string | null;
200
+ /** Roadmap slug this session claims, or null when it has not picked one. */
201
+ roadmap_slug: string | null;
202
+ /** ISO-8601 UTC, set once at registration. */
203
+ started_at: string;
204
+ /** ISO-8601 UTC, rewritten every heartbeat. The liveness signal. */
205
+ last_seen: string;
206
+ }
207
+
208
+ /**
209
+ * Filename-safe stem for a session id. Path separators and `..` collapse to `_`
210
+ * so a hostile or merely odd id cannot escape the register directory — the same
211
+ * traversal guard the dispatcher applies to its feedback directory.
212
+ *
213
+ * The fallback fires when the sanitised stem carries **no alphanumeric
214
+ * character**, not merely when it is empty. `'///'` sanitises to `'___'`, which
215
+ * is a legal filename and a useless one: it distinguishes nothing, so two
216
+ * different odd session ids would silently share a record. Requiring one
217
+ * alphanumeric makes the degenerate case land on the explicit marker instead.
218
+ */
219
+ export function safe_stem(session_id: string): string {
220
+ const collapsed = String(session_id ?? '')
221
+ .replace(/\.\./g, '_')
222
+ .replace(/[/\\]/g, '_')
223
+ .replace(/[^A-Za-z0-9._-]/g, '_')
224
+ .slice(0, 128);
225
+ return /[A-Za-z0-9]/.test(collapsed) ? collapsed : 'unknown-session';
226
+ }
227
+
228
+ /**
229
+ * The register directory for a workspace, or `null` when there is no git common
230
+ * dir to anchor it to. Does **not** create the directory — writers do.
231
+ */
232
+ export function register_dir(workspace_root: string): string | null {
233
+ const common = git_common_dir(workspace_root);
234
+ return common === null ? null : path.join(common, REGISTER_DIRNAME);
235
+ }
236
+
237
+ /** ISO-8601 UTC at seconds precision, matching the chat-history stamp format. */
238
+ export function iso_now(now: Date = new Date()): string {
239
+ return now.toISOString().replace(/\.\d{3}Z$/, '+00:00');
240
+ }
241
+
242
+ /** Parse an ISO stamp to epoch ms; `null` when unparseable. */
243
+ function epoch_ms(stamp: string | null | undefined): number | null {
244
+ if (typeof stamp !== 'string' || stamp.length === 0) return null;
245
+ const ms = Date.parse(stamp);
246
+ return Number.isFinite(ms) ? ms : null;
247
+ }
248
+
249
+ /** True when `rec` has not been heard from within its own platform's TTL. */
250
+ export function is_expired(rec: SessionRecord, now: Date = new Date()): boolean {
251
+ const seen = epoch_ms(rec.last_seen);
252
+ if (seen === null) return true; // unreadable stamp → not evidence of life
253
+ const age_s = (now.getTime() - seen) / 1000;
254
+ return age_s > ttl_seconds_for(rec.platform);
255
+ }
256
+
257
+ function is_record(v: unknown): v is SessionRecord {
258
+ if (typeof v !== 'object' || v === null) return false;
259
+ const o = v as Record<string, unknown>;
260
+ return typeof o['session_id'] === 'string' && typeof o['last_seen'] === 'string';
261
+ }
262
+
263
+ /**
264
+ * Write (or overwrite) this session's own record. Returns `true` on success.
265
+ * Never throws — a session that cannot write the register still starts.
266
+ */
267
+ export function write_record(dir: string, rec: SessionRecord): boolean {
268
+ try {
269
+ const target = path.join(dir, `${safe_stem(rec.session_id)}.json`);
270
+ write_atomic(target, `${JSON.stringify(rec, null, 2)}\n`);
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /** Read this session's own record back, or `null` when it is absent/unreadable. */
278
+ export function read_own_record(dir: string, session_id: string): SessionRecord | null {
279
+ try {
280
+ const target = path.join(dir, `${safe_stem(session_id)}.json`);
281
+ const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
282
+ return is_record(parsed) ? parsed : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ /** Remove this session's own record. Best-effort; a failure is not an error. */
289
+ export function delete_record(dir: string, session_id: string): boolean {
290
+ try {
291
+ fs.unlinkSync(path.join(dir, `${safe_stem(session_id)}.json`));
292
+ return true;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ export interface ReadLiveOptions {
299
+ /** Unlink expired records as they are encountered. Default `false`. */
300
+ prune?: boolean;
301
+ /** Clock injection for tests. */
302
+ now?: Date;
303
+ }
304
+
305
+ /**
306
+ * Every live record in the register, optionally pruning expired ones as it goes.
307
+ *
308
+ * Expiry is evaluated per record against **its own** platform's TTL. A file that
309
+ * does not parse as a record is treated as expired: it carries no evidence of
310
+ * life, and leaving it would make the register grow without bound on a host that
311
+ * writes garbage.
312
+ *
313
+ * Returns `[]` on any failure — including "the directory does not exist", which
314
+ * is the normal state before the first session registers.
315
+ */
316
+ export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
317
+ const now = options.now ?? new Date();
318
+ const prune = options.prune === true;
319
+ let names: string[];
320
+ try {
321
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
322
+ } catch {
323
+ return [];
324
+ }
325
+ const live: SessionRecord[] = [];
326
+ for (const name of names) {
327
+ const full = path.join(dir, name);
328
+ let rec: SessionRecord | null = null;
329
+ try {
330
+ const parsed: unknown = JSON.parse(fs.readFileSync(full, 'utf-8'));
331
+ rec = is_record(parsed) ? parsed : null;
332
+ } catch {
333
+ rec = null;
334
+ }
335
+ if (rec === null || is_expired(rec, now)) {
336
+ if (prune) {
337
+ try {
338
+ fs.unlinkSync(full);
339
+ } catch {
340
+ /* another session may have pruned it first — fine */
341
+ }
342
+ }
343
+ continue;
344
+ }
345
+ live.push(rec);
346
+ }
347
+ return live;
348
+ }
349
+
350
+ /**
351
+ * Live records belonging to sessions other than `session_id` — the set a
352
+ * starting session actually cares about.
353
+ */
354
+ export function foreign_live_records(
355
+ dir: string,
356
+ session_id: string,
357
+ options: ReadLiveOptions = {},
358
+ ): SessionRecord[] {
359
+ const own = safe_stem(session_id);
360
+ return read_live_records(dir, options).filter((r) => safe_stem(r.session_id) !== own);
361
+ }
@@ -10,7 +10,10 @@
10
10
  # stitch.sh <manifest.json> <output.mp4>
11
11
  # [--skip-scene <id> ...]
12
12
  # [--abort-on-missing | --continue]
13
- # [--crossfade <seconds>]
13
+ #
14
+ # NOT a flag: --crossfade. It is parsed and REFUSED (exit 2). See
15
+ # "Refused flags" below — it is listed so a caller who read the old
16
+ # usage line gets an error instead of a hard cut.
14
17
  #
15
18
  # Failure semantics:
16
19
  # - Missing clip → fail loud with scene_id and re-render hint
@@ -21,6 +24,22 @@
21
24
  # <project>/scenes/<id>/error.json; stitch.sh checks for that
22
25
  # file alongside the clip and surfaces it instead of an opaque
23
26
  # ffmpeg error.
27
+ #
28
+ # Refused flags:
29
+ # --crossfade <seconds> — accepted by the parser, then REFUSED with
30
+ # exit 2 before any work. It used to print "not yet implemented"
31
+ # to stderr and fall straight through to the plain concat, so the
32
+ # script reported success (exit 0, success JSON) while delivering a
33
+ # hard cut. Worse, the notice sat AFTER the dry-run exit, and
34
+ # AIV_DRYRUN defaults to true — so in the default mode the flag was
35
+ # accepted in total silence.
36
+ # A crossfade is not a small addition here: the output path is
37
+ # `-f concat -c copy`, and stream copy precludes any filtergraph, so
38
+ # xfade/acrossfade need a re-encode path that does not exist. It was
39
+ # also never reachable from a governed surface: /video:stitch passes
40
+ # only --skip-scene and --continue and rejects anything else. So the
41
+ # honest state is a refusal, not a silent downgrade and not a
42
+ # filtergraph shipped into a script with no test harness.
24
43
 
25
44
  set -euo pipefail
26
45
 
@@ -39,19 +58,23 @@ esac
39
58
  MANIFEST="${1:-}"
40
59
  OUTPUT="${2:-}"
41
60
  [ -n "${MANIFEST}" ] && [ -n "${OUTPUT}" ] \
42
- || aiv_die 2 "usage: stitch.sh <manifest.json> <output.mp4> [--skip-scene <id>] [--abort-on-missing|--continue] [--crossfade <s>]"
61
+ || aiv_die 2 "usage: stitch.sh <manifest.json> <output.mp4> [--skip-scene <id>] [--abort-on-missing|--continue]"
43
62
  [ -r "${MANIFEST}" ] || aiv_die 2 "manifest not readable: ${MANIFEST}"
44
63
  shift 2 || true
45
64
 
46
65
  SKIP_IDS=""
47
66
  MISSING_POLICY="abort"
48
- CROSSFADE=""
49
67
  while [ "$#" -gt 0 ]; do
50
68
  case "$1" in
51
69
  --skip-scene) SKIP_IDS="${SKIP_IDS} ${2:-}"; shift 2 ;;
52
70
  --abort-on-missing) MISSING_POLICY="abort"; shift ;;
53
71
  --continue) MISSING_POLICY="continue"; shift ;;
54
- --crossfade) CROSSFADE="${2:-}"; shift 2 ;;
72
+ --crossfade)
73
+ # Refused HERE — before the dry-run branch, before ffmpeg, before any
74
+ # output. The old code warned after the dry-run exit and then concatenated
75
+ # anyway, so a caller asking for a crossfade got a hard cut and exit 0.
76
+ aiv_die 2 "stitch.sh: --crossfade is not implemented and will not be silently downgraded. The output path is 'ffmpeg -f concat -c copy', and stream copy precludes any filtergraph, so xfade/acrossfade require a re-encode path this script does not have. Re-run without --crossfade for a hard-cut concat, or open a roadmap item for the re-encode path."
77
+ ;;
55
78
  *) aiv_die 2 "stitch.sh: unknown flag '$1'" ;;
56
79
  esac
57
80
  done
@@ -149,11 +172,6 @@ if [ "${DRYRUN_FLAG}" -eq 1 ]; then
149
172
  exit 0
150
173
  fi
151
174
 
152
- if [ -n "${CROSSFADE}" ]; then
153
- printf 'stitch: crossfade=%ss requested — passing through ffmpeg xfade filter not yet implemented (concat path used)\n' \
154
- "${CROSSFADE}" >&2
155
- fi
156
-
157
175
  ffmpeg -loglevel error -y -f concat -safe 0 -i "${CONCAT_LIST}" \
158
176
  -c copy "${OUTPUT}" >/dev/null \
159
177
  || aiv_die 8 "ffmpeg concat failed (manifest=${MANIFEST}, output=${OUTPUT})"
@@ -205,10 +205,17 @@ const _VACUOUS_PATTERNS: readonly RegExp[] = [
205
205
  export function isVacuousOutput(output: string): boolean {
206
206
  const text = output.trim();
207
207
  if (!text) {
208
- // A verification command that printed nothing at all is not evidence either.
209
- return true;
208
+ // Silence is the Unix convention for success: a clean `tsc --noEmit`,
209
+ // `eslint`, or `phpstan` prints nothing. Round 2 caught this reading empty
210
+ // output as vacuity, which quietly stopped counting the most common green
211
+ // signal in this repo. Genuinely unreadable CI polls are handled separately
212
+ // (`pendingCount` returns null and the poll counts for nothing).
213
+ return false;
210
214
  }
211
- return _VACUOUS_PATTERNS.some((re) => re.test(text));
215
+ // Per RESULT LINE, not per blob: a composite run whose 812 tests passed and
216
+ // whose one empty sub-package printed "No test files found" is evidence.
217
+ const lines = text.split("\n").filter((l) => l.trim());
218
+ return lines.every((l) => _VACUOUS_PATTERNS.some((re) => re.test(l)));
212
219
  }
213
220
 
214
221
  /** Commands that poll CI state rather than produce a local result. */
@@ -228,8 +235,20 @@ export function pendingCount(output: string): number | null {
228
235
  return Number(m[1]);
229
236
  }
230
237
  // `gh pr checks` prints one row per check; count the in-progress markers.
238
+ // `gh pr checks` prints one row per check (`name\tpass\t1m2s\t…`). Count the
239
+ // in-flight rows; a table with rows but none pending IS a settle, which the
240
+ // first version could not see because it returned null and never counted.
231
241
  const rows = output.match(/^\S.*\b(pending|in_progress|queued)\b/gim);
232
- return rows ? rows.length : null;
242
+ if (rows) {
243
+ return rows.length;
244
+ }
245
+ if (/\bno checks reported\b/i.test(output)) {
246
+ return null; // the push→registration gap — not a settle
247
+ }
248
+ if (/^\S+\s+(pass|fail|skipping|successful|failing)\b/im.test(output)) {
249
+ return 0; // a real result table with no in-flight rows
250
+ }
251
+ return null;
233
252
  }
234
253
 
235
254
  /**