session-orchestrator 4.1.0 → 5.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -24,7 +24,7 @@
24
24
  * {"action":"appended","path":"<file>","session_id":"<id>","schema_version":2}
25
25
  */
26
26
 
27
- import { readFileSync } from 'node:fs';
27
+ import { existsSync, readFileSync } from 'node:fs';
28
28
  import { dirname, join } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { appendJsonl } from './lib/common.mjs';
@@ -33,6 +33,7 @@ import {
33
33
  deriveMemoryCleanupSignal,
34
34
  stampMemoryCleanup,
35
35
  } from './lib/memory-cleanup-stamp.mjs';
36
+ import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
36
37
  import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
37
38
  import {
38
39
  validateSession,
@@ -153,6 +154,76 @@ async function main() {
153
154
  }
154
155
  }
155
156
 
157
+ // `session_profile` derivation (#1247 — GitLab issue "key the effective-sizing
158
+ // row on waves x profile"). The mandatory write path is the coordinator
159
+ // composing `$METRICS_ENTRY` in prose (session-metrics-write.md), which never
160
+ // reliably set this field — so it is read here instead, from this repo's own
161
+ // STATE.md frontmatter via `readSessionProfile()` (scripts/lib/state-md.mjs).
162
+ //
163
+ // Precedence: an EXPLICIT `session_profile` KEY on the incoming record (even
164
+ // `null`) wins and is never overwritten — same "explicit assertion beats
165
+ // derivation" convention as `memory_cleanup_at` above. Absence of a profile in
166
+ // STATE.md (readSessionProfile returns null) leaves the field OMITTED —
167
+ // never coerced to `''`/`'none'`/a literal `"null"` string.
168
+ //
169
+ // OWNERSHIP: STATE.md belongs to the session named in its OWN frontmatter
170
+ // (`session:`), which in a shared working copy need not be the session this
171
+ // record describes — two parallel sessions, or a `/close` run after a
172
+ // foreign `/plan` session left its STATE.md behind (see
173
+ // `.claude/rules/parallel-sessions.md`). Filing session B's waves under
174
+ // session A's profile is exactly the cross-contamination #1247 removes, only
175
+ // inverted. So the profile is adopted ONLY when the frontmatter `session`
176
+ // equals this record's `session_id`; on any mismatch (or an unprovable
177
+ // ownership, i.e. no `session` key) the field is OMITTED — never guessed.
178
+ //
179
+ // FAIL-SAFE: the read is enrichment; the session record is the load-bearing
180
+ // artefact. Any failure reading or parsing STATE.md (EISDIR when the path is
181
+ // a directory, EACCES/EPERM, a truncated file) omits the field and lets the
182
+ // record through — it must never be the reason `sessions.jsonl` gains no
183
+ // line at all.
184
+ if (!Object.prototype.hasOwnProperty.call(repaired, 'session_profile')) {
185
+ let stateMdContents = '';
186
+ try {
187
+ const stateMdPath = resolveStateMdPath(process.cwd());
188
+ if (existsSync(stateMdPath)) stateMdContents = readFileSync(stateMdPath, 'utf8');
189
+ } catch (err) {
190
+ process.stderr.write(
191
+ `emit-session: WARN could not read STATE.md for session_profile derivation ` +
192
+ `(${err?.message ?? err}); omitting session_profile\n`
193
+ );
194
+ stateMdContents = '';
195
+ }
196
+ // No initialiser: both the try and the catch below assign `profile`.
197
+ let profile;
198
+ let owner = null;
199
+ try {
200
+ profile = readSessionProfile(stateMdContents);
201
+ const parsed = parseStateMd(stateMdContents);
202
+ const rawOwner = parsed?.frontmatter?.session;
203
+ owner = typeof rawOwner === 'string' && rawOwner.trim().length > 0 ? rawOwner.trim() : null;
204
+ } catch (err) {
205
+ process.stderr.write(
206
+ `emit-session: WARN could not parse STATE.md for session_profile derivation ` +
207
+ `(${err?.message ?? err}); omitting session_profile\n`
208
+ );
209
+ profile = null;
210
+ }
211
+ if (profile !== null) {
212
+ if (owner !== null && owner === repaired.session_id) {
213
+ repaired = { ...repaired, session_profile: profile };
214
+ } else {
215
+ // A visible omission: silence here is indistinguishable from "STATE.md
216
+ // carries no profile", and a foreign STATE.md in this working copy is
217
+ // precisely what the operator wants to know about.
218
+ process.stderr.write(
219
+ `emit-session: WARN STATE.md session_profile=${profile} belongs to ` +
220
+ `session=${owner ?? '<absent>'}, not session_id=${repaired.session_id ?? '<unknown>'}; ` +
221
+ `omitting session_profile\n`
222
+ );
223
+ }
224
+ }
225
+ }
226
+
156
227
  let validated;
157
228
  try {
158
229
  validated = validateSession(repaired);
@@ -8,6 +8,30 @@
8
8
  * - `agent-status.jsonl` — append-only event log (one record per push).
9
9
  * - `agent-status-current.json` — last-write-wins (LWW) map keyed by agentId.
10
10
  *
11
+ * ## Source-of-truth contract (#1342)
12
+ *
13
+ * **`agent-status.jsonl` is the SOURCE OF TRUTH of this status channel. The
14
+ * current-map is a REBUILDABLE CACHE of it.** `pushRecord()` appends to the
15
+ * ledger FIRST and only then takes `agent-status.lock` to update the map, so
16
+ * every failure of the second half (lock timeout because a foreign-host lock is
17
+ * held, process death between the two writes, map corruption) leaves a ledger
18
+ * that is AHEAD of the cache. Before #1342 the map was read as if current in
19
+ * exactly those cases and the operator saw `running` for an agent that had
20
+ * already reported `completed`.
21
+ *
22
+ * Therefore:
23
+ * - `rebuildCurrentFromLedger()` folds a bounded TAIL of the ledger into the
24
+ * same map shape and is the authority whenever it is newer than the cache.
25
+ * - `readCurrentStatus()` returns PROVENANCE (`source`, `at`, `degraded`) so a
26
+ * consumer can never mistake a stale cache for live state. It NEVER invents
27
+ * a state: an agent visible only in a discarded partial line is `unknown`,
28
+ * not `running`.
29
+ * - Neither reader writes anything — a rebuild is idempotent on disk and must
30
+ * stay that way (it is a read path in hooks and a tmux poll loop).
31
+ *
32
+ * This contract is local to the agent-status channel. It changes nothing about
33
+ * STATE.md ownership or quality-gate semantics.
34
+ *
11
35
  * Best-effort telemetry contract: a status push must NEVER crash or block a
12
36
  * wave. Every exported function is no-throw and returns a structured result:
13
37
  *
@@ -29,6 +53,7 @@
29
53
 
30
54
  import fs from 'node:fs';
31
55
  import path from 'node:path';
56
+ import { isDeepStrictEqual } from 'node:util';
32
57
 
33
58
  import { appendJsonl } from './common.mjs';
34
59
  import { writeJsonAtomicSync } from './io.mjs';
@@ -51,6 +76,31 @@ const POLL_MS = 100;
51
76
  // envelope (keys, ts, numbers).
52
77
  const MAX_TEXT_LEN = 256;
53
78
 
79
+ // Bounded rebuild window (#1342). A wave's live status traffic is a handful of
80
+ // records per agent, so the newest state always sits in the last few KiB —
81
+ // 256 KiB is ~1500 typical 170-byte records, three orders of magnitude of
82
+ // headroom, and it keeps the rebuild a single bounded read instead of a scan of
83
+ // an unbounded append-only file.
84
+ // CEILING: an agent whose newest record sits FURTHER back than `maxBytes` is
85
+ // invisible to the rebuild (it stays whatever the cache says, or absent).
86
+ // REVISIT-TRIGGER: if a session ever pushes more than ~1500 status records, or
87
+ // if a rebuilt view is observed missing a live agent, raise this constant or
88
+ // switch to a reverse-chunked scan that stops once every known agentId is seen.
89
+ const DEFAULT_REBUILD_MAX_BYTES = 256 * 1024;
90
+
91
+ // Identity fields a record may carry to bind it to a run/session/wave. Records
92
+ // carrying none of them are `legacy` — they are NEVER auto-assigned to the
93
+ // newest wave (#1342 item 4).
94
+ const BINDING_KEYS = ['sessionId', 'session_id', 'runId', 'run_id', 'waveKey', 'wave_key', 'wave'];
95
+
96
+ // agentIds that must never become a map key. `entries['__proto__'] = rec` on a
97
+ // normal object REPLACES the prototype instead of storing the record (the record
98
+ // vanishes AND every later lookup walks a foreign prototype), and
99
+ // `constructor`/`prototype` are the same class of confusion. Every map this
100
+ // module builds is null-prototype, so the assignment itself is safe — these keys
101
+ // are rejected outright so a poisoned ledger line cannot reappear as an agent.
102
+ const UNSAFE_MAP_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
103
+
54
104
  // ---------------------------------------------------------------------------
55
105
  // Path helpers
56
106
  // ---------------------------------------------------------------------------
@@ -75,6 +125,22 @@ function lockPathFor(repoRoot) {
75
125
  // Internal utilities
76
126
  // ---------------------------------------------------------------------------
77
127
 
128
+ /**
129
+ * Copy a plain object into a NULL-PROTOTYPE map, dropping `UNSAFE_MAP_KEYS`.
130
+ * `JSON.parse` happily produces an OWN `__proto__` property, so a poisoned cache
131
+ * file would otherwise travel into a consumer's `entries` map.
132
+ * @param {Record<string, object>|null|undefined} obj
133
+ * @returns {Record<string, object>}
134
+ */
135
+ function nullProtoMap(obj) {
136
+ const out = Object.create(null);
137
+ for (const k of Object.keys(obj ?? {})) {
138
+ if (UNSAFE_MAP_KEYS.has(k)) continue;
139
+ out[k] = obj[k];
140
+ }
141
+ return out;
142
+ }
143
+
78
144
  /**
79
145
  * Truncate a free-text field to MAX_TEXT_LEN so a single JSONL line stays under
80
146
  * the PIPE_BUF floor. Non-strings pass through untouched.
@@ -175,11 +241,21 @@ function releaseLock(lockFile, _myBody) {
175
241
  * @returns {Record<string, object>}
176
242
  */
177
243
  function readCurrentMap(currentFile) {
244
+ return readCurrentMapDetailed(currentFile).entries;
245
+ }
246
+
247
+ /**
248
+ * Same read as `readCurrentMap`, but it reports WHY the map is empty (#1342) —
249
+ * "absent" and "unreadable" are different facts, and only the second one makes
250
+ * the ledger the authority for provenance purposes.
251
+ *
252
+ * @param {string} currentFile
253
+ * @returns {{ ok: boolean, entries: Record<string, object>, reason: 'ok'|'cache-missing'|'cache-unreadable' }}
254
+ */
255
+ function readCurrentMapDetailed(currentFile) {
256
+ let raw;
178
257
  try {
179
- const raw = fs.readFileSync(currentFile, 'utf8');
180
- const obj = JSON.parse(raw);
181
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
182
- return {};
258
+ raw = fs.readFileSync(currentFile, 'utf8');
183
259
  } catch (err) {
184
260
  if (!err || err.code !== 'ENOENT') {
185
261
  process.stderr.write(
@@ -187,8 +263,73 @@ function readCurrentMap(currentFile) {
187
263
  `(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
188
264
  'treating as EMPTY, counts below are floors\n',
189
265
  );
266
+ return { ok: false, entries: Object.create(null), reason: 'cache-unreadable' };
267
+ }
268
+ return { ok: false, entries: Object.create(null), reason: 'cache-missing' };
269
+ }
270
+ try {
271
+ const obj = JSON.parse(raw);
272
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
273
+ return { ok: true, entries: nullProtoMap(obj), reason: 'ok' };
274
+ }
275
+ } catch {
276
+ /* fall through to the unreadable verdict below */
277
+ }
278
+ return { ok: false, entries: Object.create(null), reason: 'cache-unreadable' };
279
+ }
280
+
281
+ /**
282
+ * Parse a record's `ts` into epoch millis. Returns null when absent/unparseable
283
+ * — such a record is never allowed to win a fold on timestamp grounds.
284
+ * @param {*} rec
285
+ * @returns {number|null}
286
+ */
287
+ function recordTsMs(rec) {
288
+ const ms = Date.parse(rec?.ts ?? '');
289
+ return Number.isFinite(ms) ? ms : null;
290
+ }
291
+
292
+ /**
293
+ * Newest parsable record timestamp across a map's values, or null.
294
+ * @param {Record<string, object>} entries
295
+ * @returns {number|null}
296
+ */
297
+ function newestTsMs(entries) {
298
+ let newest = null;
299
+ for (const rec of Object.values(entries ?? {})) {
300
+ const ms = recordTsMs(rec);
301
+ if (ms !== null && (newest === null || ms > newest)) newest = ms;
302
+ }
303
+ return newest;
304
+ }
305
+
306
+ /**
307
+ * Read the last `maxBytes` of a file. Returns the raw text plus whether the
308
+ * window cut into the file (i.e. the first line in `text` may be partial).
309
+ * @param {string} file
310
+ * @param {number} maxBytes
311
+ * @returns {{ text: string, size: number, cut: boolean }}
312
+ */
313
+ function readTail(file, maxBytes) {
314
+ const fd = fs.openSync(file, 'r');
315
+ try {
316
+ const size = fs.fstatSync(fd).size;
317
+ const want = Math.min(size, maxBytes);
318
+ const start = size - want;
319
+ const buf = Buffer.allocUnsafe(want);
320
+ let read = 0;
321
+ while (read < want) {
322
+ const n = fs.readSync(fd, buf, read, want - read, start + read);
323
+ if (n <= 0) break;
324
+ read += n;
325
+ }
326
+ return { text: buf.subarray(0, read).toString('utf8'), size, cut: start > 0 };
327
+ } finally {
328
+ try {
329
+ fs.closeSync(fd);
330
+ } catch {
331
+ /* best-effort */
190
332
  }
191
- return {};
192
333
  }
193
334
  }
194
335
 
@@ -304,11 +445,302 @@ export async function setProgress(agentId, progress = {}, opts = {}) {
304
445
  }
305
446
 
306
447
  /**
307
- * Read the current LWW status map. No-throw returns {} on miss or parse error.
448
+ * Rebuild the current-status map from a bounded TAIL of the append-only ledger
449
+ * (#1342). The ledger is the source of truth; this is how a consumer reads it
450
+ * without trusting the cache. READ-ONLY and idempotent — it never writes the
451
+ * ledger, the cache, or anything else. No-throw.
452
+ *
453
+ * Fold rule (identity binding): one entry per `agentId`, won by the greatest
454
+ * parsable `ts`; ties go to the later line. A record whose `ts` is absent or
455
+ * unparseable can only win when NO timestamped record exists for that agentId,
456
+ * and is then marked `binding: 'unknown'`. A record carrying none of the
457
+ * run/session/wave fields is marked `binding: 'legacy'` — never promoted into
458
+ * the newest wave. An older-session record therefore cannot overwrite a newer
459
+ * session's record for the same agentId when the timestamps say otherwise.
308
460
  *
309
- * @param {{ repoRoot?: string }} [opts]
310
- * @returns {Record<string, { agentId: string, kind: string, ts: string, text?: string, step?: number, total?: number, label?: string }>}
461
+ * @param {{ repoRoot?: string, maxBytes?: number }} [opts]
462
+ * @returns {{
463
+ * entries: Record<string, object>,
464
+ * at: string|null,
465
+ * scannedBytes: number,
466
+ * fileSize: number,
467
+ * degraded: { reason: string, reasons: string[], partialLines: number, parseErrors: number, unboundRecords: number, tailTruncated: boolean }|null
468
+ * }}
469
+ */
470
+ export function rebuildCurrentFromLedger(opts = {}) {
471
+ const jsonlFile = jsonlPathFor(opts?.repoRoot);
472
+ const maxBytes =
473
+ typeof opts?.maxBytes === 'number' && opts.maxBytes > 0
474
+ ? opts.maxBytes
475
+ : DEFAULT_REBUILD_MAX_BYTES;
476
+
477
+ const reasons = [];
478
+ let partialLines = 0;
479
+ let parseErrors = 0;
480
+ let unboundRecords = 0;
481
+
482
+ let tail;
483
+ try {
484
+ tail = readTail(jsonlFile, maxBytes);
485
+ } catch (err) {
486
+ const reason = err?.code === 'ENOENT' ? 'ledger-missing' : 'ledger-unreadable';
487
+ return {
488
+ entries: Object.create(null),
489
+ at: null,
490
+ scannedBytes: 0,
491
+ fileSize: 0,
492
+ degraded: {
493
+ reason,
494
+ reasons: [reason],
495
+ partialLines: 0,
496
+ parseErrors: 0,
497
+ unboundRecords: 0,
498
+ tailTruncated: false,
499
+ },
500
+ };
501
+ }
502
+
503
+ if (tail.size === 0) reasons.push('ledger-empty');
504
+
505
+ const lines = tail.text.split('\n');
506
+ if (tail.cut) {
507
+ // The window started mid-file: the FIRST line is (or may be) a fragment.
508
+ // Discard it and count it — never guess the state it would have carried.
509
+ lines.shift();
510
+ partialLines += 1;
511
+ reasons.push('tail-truncated');
512
+ }
513
+ // A trailing newline yields one empty final element; a MISSING trailing
514
+ // newline means the last line is an in-flight partial write.
515
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
516
+ lines.pop();
517
+ } else if (lines.length > 0 && tail.size > 0) {
518
+ const incomplete = lines.pop();
519
+ if (incomplete.trim().length > 0) {
520
+ partialLines += 1;
521
+ reasons.push('incomplete-last-line');
522
+ }
523
+ }
524
+
525
+ /** @type {Record<string, object>} */
526
+ const entries = Object.create(null);
527
+ /** @type {Record<string, number|null>} */
528
+ const bestTs = Object.create(null);
529
+
530
+ for (const line of lines) {
531
+ if (line.trim().length === 0) continue;
532
+ let rec;
533
+ try {
534
+ rec = JSON.parse(line);
535
+ } catch {
536
+ parseErrors += 1;
537
+ continue;
538
+ }
539
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) {
540
+ parseErrors += 1;
541
+ continue;
542
+ }
543
+ if (typeof rec.agentId !== 'string' || rec.agentId.trim().length === 0) {
544
+ // No usable key — it can never be attributed to an agent. Counted, dropped.
545
+ unboundRecords += 1;
546
+ continue;
547
+ }
548
+ if (UNSAFE_MAP_KEYS.has(rec.agentId)) {
549
+ // A prototype-shaped key is not a usable map key either (see
550
+ // UNSAFE_MAP_KEYS) — same class as a missing agentId: counted, dropped.
551
+ unboundRecords += 1;
552
+ continue;
553
+ }
554
+
555
+ const ts = recordTsMs(rec);
556
+ const bound = BINDING_KEYS.some((k) => rec[k] !== undefined && rec[k] !== null);
557
+ const prevTs = Object.prototype.hasOwnProperty.call(bestTs, rec.agentId)
558
+ ? bestTs[rec.agentId]
559
+ : undefined;
560
+
561
+ if (prevTs !== undefined) {
562
+ if (ts === null) continue; // untimestamped never displaces a known record
563
+ if (prevTs !== null && ts < prevTs) continue; // older session must not win
564
+ }
565
+
566
+ bestTs[rec.agentId] = ts;
567
+ entries[rec.agentId] = { ...rec, binding: ts === null ? 'unknown' : bound ? 'bound' : 'legacy' };
568
+ }
569
+
570
+ if (parseErrors > 0) reasons.push('parse-errors');
571
+ if (unboundRecords > 0) reasons.push('records-without-agent-id');
572
+
573
+ const newest = newestTsMs(entries);
574
+ return {
575
+ entries,
576
+ at: newest === null ? null : new Date(newest).toISOString(),
577
+ scannedBytes: Buffer.byteLength(tail.text, 'utf8'),
578
+ fileSize: tail.size,
579
+ degraded:
580
+ reasons.length === 0
581
+ ? null
582
+ : {
583
+ reason: reasons[0],
584
+ reasons,
585
+ partialLines,
586
+ parseErrors,
587
+ unboundRecords,
588
+ tailTruncated: tail.cut,
589
+ },
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Read the current status of every agent WITH PROVENANCE (#1342).
595
+ *
596
+ * Return contract:
597
+ * {
598
+ * entries: Record<agentId, record>, // the map shape the cache holds
599
+ * source: 'live-map' | 'rebuilt-log' | 'stale-cache' | 'absent',
600
+ * at: string|null, // ISO ts of the newest record in `entries`
601
+ * degraded?: { reason, reasons[], partialLines, parseErrors, unboundRecords, tailTruncated }
602
+ * }
603
+ *
604
+ * Decision rule — the fold is PER agentId, never by the two views' GLOBAL newest
605
+ * timestamp. A global comparison is masked by any sibling: ledger `A completed`
606
+ * (map write lost) followed by ledger `B running` (map write ok) makes the cache's
607
+ * newest equal the ledger's newest, and `A` was then served from the cache as
608
+ * `running` with `source: live-map` — byte-identical to the pre-#1342 defect.
609
+ * So for every id in `cache ∪ ledger` the record with the newer `ts` wins (an
610
+ * equal-ms ties retain the cache only when its ledger payload agrees):
611
+ * - every entry taken from the cache → `live-map`; the cache is VERIFIED
612
+ * against a ledger (the normal path).
613
+ * - ANY entry taken from the ledger, or the cache unreadable/missing while the
614
+ * ledger has entries → `rebuilt-log` (the #1342 defect: a failed map write
615
+ * used to show the OLD state unmarked).
616
+ * - ledger missing/unreadable/empty but the cache has entries → `stale-cache`;
617
+ * the cache is all we have and is explicitly marked as unverified.
618
+ * - neither a usable ledger NOR a cache entry → `absent`: nothing is on disk to
619
+ * verify against, so this is NOT `live-map`. `entries: {}`, `at: null`, and no
620
+ * `degraded` — a fresh repo is the normal state, not a degradation (HR-101).
621
+ *
622
+ * BREAKING (documented, #1342): this used to return the bare map. Read
623
+ * `.entries` for the old value — or call `readCurrentStatusEntries()`.
624
+ *
625
+ * Entry shape note: an entry taken from the LEDGER carries the extra
626
+ * `binding: 'bound'|'legacy'|'unknown'` field the fold assigns; an entry taken
627
+ * from the cache is the record as written and has none. Since the fold is
628
+ * per-agentId, a `rebuilt-log` view can hold BOTH kinds. A consumer must treat
629
+ * `binding` as optional — its ABSENCE means "from the cache", never "bound".
630
+ *
631
+ * @param {{ repoRoot?: string, maxBytes?: number }} [opts]
632
+ * @returns {{ entries: Record<string, object>, source: 'live-map'|'rebuilt-log'|'stale-cache'|'absent', at: string|null, degraded?: object }}
311
633
  */
312
634
  export function readCurrentStatus(opts = {}) {
313
- return readCurrentMap(currentPathFor(opts?.repoRoot));
635
+ const cache = readCurrentMapDetailed(currentPathFor(opts?.repoRoot));
636
+ const rebuilt = rebuildCurrentFromLedger(opts);
637
+
638
+ const cacheNewest = newestTsMs(cache.entries);
639
+ const ledgerNewest = rebuilt.at === null ? null : Date.parse(rebuilt.at);
640
+ const ledgerUnusable =
641
+ rebuilt.degraded !== null &&
642
+ ['ledger-missing', 'ledger-unreadable', 'ledger-empty'].includes(rebuilt.degraded.reason) &&
643
+ Object.keys(rebuilt.entries).length === 0;
644
+
645
+ const reasons = rebuilt.degraded ? [...rebuilt.degraded.reasons] : [];
646
+ const counts = {
647
+ partialLines: rebuilt.degraded?.partialLines ?? 0,
648
+ parseErrors: rebuilt.degraded?.parseErrors ?? 0,
649
+ unboundRecords: rebuilt.degraded?.unboundRecords ?? 0,
650
+ tailTruncated: rebuilt.degraded?.tailTruncated ?? false,
651
+ };
652
+
653
+ let source;
654
+ let entries;
655
+ let at;
656
+
657
+ const cacheIds = Object.keys(cache.entries);
658
+ const ledgerIds = Object.keys(rebuilt.entries);
659
+
660
+ if (ledgerUnusable && cacheIds.length === 0) {
661
+ // Nothing on disk at all — no ledger to verify against AND no cached entry
662
+ // to verify. `live-map` would claim a verification that never happened.
663
+ return { entries: Object.create(null), source: 'absent', at: null };
664
+ }
665
+
666
+ if (ledgerUnusable) {
667
+ // No ledger to check the cache against, but the cache holds entries.
668
+ source = 'stale-cache';
669
+ entries = cache.entries;
670
+ at = cacheNewest;
671
+ if (!cache.ok && cache.reason === 'cache-unreadable') reasons.unshift(cache.reason);
672
+ } else {
673
+ // Per-agentId fold over `cache ∪ ledger` — see the decision rule above.
674
+ entries = Object.create(null);
675
+ let tookFromLedger = false;
676
+ for (const id of new Set([...cacheIds, ...ledgerIds])) {
677
+ const cached = Object.prototype.hasOwnProperty.call(cache.entries, id)
678
+ ? cache.entries[id]
679
+ : undefined;
680
+ const logged = Object.prototype.hasOwnProperty.call(rebuilt.entries, id)
681
+ ? rebuilt.entries[id]
682
+ : undefined;
683
+
684
+ if (cached === undefined) {
685
+ entries[id] = logged;
686
+ tookFromLedger = true;
687
+ continue;
688
+ }
689
+ if (logged === undefined) {
690
+ entries[id] = cached;
691
+ continue;
692
+ }
693
+ const cachedTs = recordTsMs(cached);
694
+ const loggedTs = recordTsMs(logged);
695
+ // Two real pushes can share a millisecond. The ledger fold keeps the
696
+ // last append at that timestamp; a failed map write must not hide it.
697
+ // Ignore only the fold's derived binding marker. Cache-only metadata
698
+ // remains intact when the actual ledger payload agrees.
699
+ const conflictingTie =
700
+ loggedTs !== null &&
701
+ loggedTs === cachedTs &&
702
+ [...new Set([...Object.keys(logged), 'text', 'step', 'total', 'label', ...BINDING_KEYS])].some(
703
+ (key) => key !== 'binding' && !isDeepStrictEqual(logged[key], cached[key]),
704
+ );
705
+ if (loggedTs !== null && (cachedTs === null || loggedTs > cachedTs || conflictingTie)) {
706
+ entries[id] = logged;
707
+ tookFromLedger = true;
708
+ } else {
709
+ entries[id] = cached;
710
+ }
711
+ }
712
+
713
+ at = newestTsMs(entries);
714
+ if (tookFromLedger || (!cache.ok && ledgerIds.length > 0)) {
715
+ source = 'rebuilt-log';
716
+ if (cache.reason !== 'ok') reasons.unshift(cache.reason);
717
+ else reasons.unshift('cache-behind-ledger');
718
+ } else {
719
+ source = 'live-map';
720
+ // The ledger is BEHIND the cache — it was rotated or truncated under us.
721
+ if (cacheNewest !== null && ledgerNewest !== null && ledgerNewest < cacheNewest) {
722
+ reasons.unshift('ledger-behind-cache');
723
+ }
724
+ }
725
+ }
726
+
727
+ const out = {
728
+ entries,
729
+ source,
730
+ at: at === null ? null : new Date(at).toISOString(),
731
+ };
732
+ if (reasons.length > 0) out.degraded = { reason: reasons[0], reasons, ...counts };
733
+ return out;
734
+ }
735
+
736
+ /**
737
+ * Backwards-compatible accessor: the bare `Record<agentId, record>` map that
738
+ * `readCurrentStatus()` returned before #1342, resolved through the same
739
+ * provenance rule (so it, too, prefers the ledger over a stale cache).
740
+ *
741
+ * @param {{ repoRoot?: string, maxBytes?: number }} [opts]
742
+ * @returns {Record<string, object>}
743
+ */
744
+ export function readCurrentStatusEntries(opts = {}) {
745
+ return readCurrentStatus(opts).entries;
314
746
  }
@@ -186,8 +186,9 @@ export const CRITERIA = Object.freeze({
186
186
  appliesTo: 'block',
187
187
  hurdle: 'H1',
188
188
  measures:
189
- 'Zeichen der Kopfzeile. Über 12 schneidet das Tool selbst ab das ist keine ' +
190
- 'Stilfrage, sondern eine harte Grenze.',
189
+ 'Zeichen der Kopfzeile. 12 ist das von der Tool-Beschreibung genannte Budget ' +
190
+ '(`max 12 chars`) — in VORLAGEN eine harte Grenze, weil Kürzen dort nichts ' +
191
+ 'kostet; zur Laufzeit nur beratend (siehe `evidence` an HURDLES.H1).',
191
192
  }),
192
193
  K6: Object.freeze({
193
194
  id: 'K6',
@@ -251,7 +252,13 @@ export const HURDLES = Object.freeze({
251
252
  criterion: 'K5',
252
253
  evidence:
253
254
  'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
254
- 'Spitzenwert 54 Zeichen. Das Tool schneidet ab der Operator sieht den Rest nie.',
255
+ 'Spitzenwert 54 Zeichen. Die 12 ist die Stilangabe der Tool-Beschreibung ' +
256
+ '(`max 12 chars`), KEINE erzwungene Grenze: im Bundle 2.1.268 steht die Zahl nur ' +
257
+ 'in ebendieser Beschreibung, es gibt kein `.max(12)` im Zod-Schema und keinen ' +
258
+ 'Render-Pfad, der sie liest — 125 längere Kopfzeilen wurden vom Tool angenommen ' +
259
+ 'und beantwortet (gemessen 2026-09-11). Deshalb gilt H1 nur für VORLAGEN, wo ein ' +
260
+ 'Autor kostenlos kürzen kann; zur Laufzeit meldet der Hook sie und blockt nicht ' +
261
+ '(siehe BLOCKING_HURDLES in hooks/pre-auq-clarity.mjs).',
255
262
  }),
256
263
  H2: Object.freeze({
257
264
  id: 'H2',