session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -6,8 +6,15 @@
6
6
  * - Flow-style integer arrays (`[1, 2, 3]`)
7
7
  * - Block-style sequences of mappings (issue #244), e.g. `docs-tasks:` with
8
8
  * indented `- key: value` entries. Only one nesting level supported.
9
+ * - Single-line FLOW MAPPINGS as list items (`- { k: v, … }`) — hand-written
10
+ * but valid YAML, parsed into the same plain object a block item yields and
11
+ * re-emitted in block notation on the next serialize (#1111).
9
12
  *
10
13
  * That is the full grammar permitted by skills/_shared/state-ownership.md.
14
+ * A list item this grammar cannot represent (a flow SEQUENCE `- [a, b]`, or a
15
+ * flow mapping whose interior is malformed) is dropped from ITS OWN LIST and
16
+ * reported on `parseStateMd(...).warnings` — never escalated to a null
17
+ * document. See `parseBlockValue` for why that scoping is load-bearing.
11
18
  *
12
19
  * Never throws. Returns null for unparseable input rather than raising.
13
20
  *
@@ -35,17 +42,28 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
35
42
  /**
36
43
  * Parses a STATE.md file into frontmatter + body.
37
44
  *
45
+ * `warnings` is OMITTED (not `null`, not `[]`) when the parse was clean, so a
46
+ * caller pinning the whole return with `toEqual({ frontmatter, body })` still
47
+ * matches and no consumer has to learn about it to keep working. When present
48
+ * it is a non-empty array of `{ key, index, reason }` records naming list items
49
+ * that were dropped from `frontmatter[key]` — `index` addresses the RAW source
50
+ * item position (dropped items included), so it stays quotable against the file
51
+ * even though the surviving array is shorter.
52
+ *
38
53
  * @param {string} contents
39
- * @returns {{frontmatter: object, body: string}|null}
54
+ * @returns {{frontmatter: object, body: string, warnings?: Array<{key: string, index: number, reason: string}>}|null}
40
55
  */
41
56
  export function parseStateMd(contents) {
42
57
  if (typeof contents !== 'string') return null;
43
58
  const match = FRONTMATTER_RE.exec(contents);
44
59
  if (!match) return null;
45
60
  const [, fmText, body] = match;
46
- const frontmatter = parseFrontmatter(fmText);
61
+ const warnings = [];
62
+ const frontmatter = parseFrontmatter(fmText, warnings);
47
63
  if (frontmatter === null) return null;
48
- return { frontmatter, body: body.startsWith('\n') ? body.slice(1) : body };
64
+ const parsed = { frontmatter, body: body.startsWith('\n') ? body.slice(1) : body };
65
+ if (warnings.length > 0) parsed.warnings = warnings;
66
+ return parsed;
49
67
  }
50
68
 
51
69
  /**
@@ -70,7 +88,7 @@ export function serializeStateMd({ frontmatter, body }) {
70
88
  return `---\n${fmLines.join('\n')}\n---\n${bodyOut}`;
71
89
  }
72
90
 
73
- function parseFrontmatter(text) {
91
+ function parseFrontmatter(text, warnings) {
74
92
  const out = {};
75
93
  const lines = text.split(/\r?\n/);
76
94
  let i = 0;
@@ -87,7 +105,7 @@ function parseFrontmatter(text) {
87
105
  if (key === '') return null;
88
106
  const valuePart = rstripped.slice(idx + 1).trim();
89
107
  if (valuePart === '') {
90
- const result = parseBlockValue(lines, i + 1);
108
+ const result = parseBlockValue(lines, i + 1, key, warnings);
91
109
  if (result === null) return null;
92
110
  out[key] = result.value;
93
111
  i = result.nextIndex;
@@ -106,8 +124,27 @@ function parseFrontmatter(text) {
106
124
  * no body) and `nextIndex === start` so the caller resumes at `start`.
107
125
  * - `value === [...]` means a block sequence was consumed.
108
126
  * Returns `null` on malformed block syntax.
127
+ *
128
+ * A list item that opens a YAML FLOW collection is resolved PER ITEM, never per
129
+ * document:
130
+ * - `- { id: m-1, task: "x", wave: 1, status: b }` parses into exactly the
131
+ * object the equivalent block item yields (and serializes back as block
132
+ * notation). Splitting it at the first colon — the #1111 bug, measured
133
+ * 2026-08-24 @ f0766e1 as `keys: ["{ id"] · status: undefined` — is what
134
+ * this must never do again.
135
+ * - Anything else flow-shaped (`- [a, b]`, or a `{ … }` whose interior is
136
+ * malformed) is dropped from THIS list with a `warnings` record. It cannot
137
+ * be kept: a non-mapping item makes `isBlockSeqOfMappings` false for the
138
+ * whole array, and the serializer would then emit the entire list through
139
+ * `serializeScalar` as `[object Object]`.
140
+ *
141
+ * Escalating either case to `null` for the WHOLE document — the first #1111 fix
142
+ * — is the failure this scoping exists to prevent: every mutator in this family
143
+ * opens with `parseStateMd(contents); if (parsed === null) return contents;`, so
144
+ * one hand-written item silently turned every STATE.md write into a no-op that
145
+ * reported success. Silent-wrong must not be traded for silent-absent.
109
146
  */
110
- function parseBlockValue(lines, start) {
147
+ function parseBlockValue(lines, start, key, warnings) {
111
148
  let i = start;
112
149
  while (i < lines.length) {
113
150
  const rstripped = lines[i].replace(/\s+$/, '');
@@ -124,6 +161,7 @@ function parseBlockValue(lines, start) {
124
161
  const indent = bulletMatch[1];
125
162
  const contIndent = indent + ' ';
126
163
  const entries = [];
164
+ let itemIndex = 0;
127
165
  while (i < lines.length) {
128
166
  const rstripped = lines[i].replace(/\s+$/, '');
129
167
  if (rstripped === '' || /^\s*#/.test(rstripped)) {
@@ -132,12 +170,26 @@ function parseBlockValue(lines, start) {
132
170
  }
133
171
  if (!rstripped.startsWith(indent + '- ')) break;
134
172
  const firstBody = rstripped.slice(indent.length + 2);
135
- const firstColon = firstBody.indexOf(':');
136
- if (firstColon === -1) return null;
137
- const firstKey = firstBody.slice(0, firstColon).trim();
138
- if (firstKey === '') return null;
139
- const entry = {};
140
- entry[firstKey] = parseScalar(firstBody.slice(firstColon + 1).trim());
173
+ // `entry === null` marks an item this grammar cannot represent: its
174
+ // continuation lines are still consumed below (so the walk stays aligned),
175
+ // but nothing is pushed onto `entries`.
176
+ let entry;
177
+ if (firstBody.startsWith('{')) {
178
+ entry = parseFlowMapping(firstBody);
179
+ if (entry === null) {
180
+ warnings.push({ key, index: itemIndex, reason: 'malformed-flow-mapping' });
181
+ }
182
+ } else if (firstBody.startsWith('[')) {
183
+ entry = null;
184
+ warnings.push({ key, index: itemIndex, reason: 'flow-sequence-item' });
185
+ } else {
186
+ const firstColon = firstBody.indexOf(':');
187
+ if (firstColon === -1) return null;
188
+ const firstKey = firstBody.slice(0, firstColon).trim();
189
+ if (firstKey === '') return null;
190
+ entry = {};
191
+ entry[firstKey] = parseScalar(firstBody.slice(firstColon + 1).trim());
192
+ }
141
193
  i++;
142
194
  while (i < lines.length) {
143
195
  const inner = lines[i].replace(/\s+$/, '');
@@ -150,16 +202,93 @@ function parseBlockValue(lines, start) {
150
202
  if (/^\s/.test(body)) return null;
151
203
  const colon = body.indexOf(':');
152
204
  if (colon === -1) return null;
153
- const key = body.slice(0, colon).trim();
154
- if (key === '') return null;
155
- entry[key] = parseScalar(body.slice(colon + 1).trim());
205
+ const contKey = body.slice(0, colon).trim();
206
+ if (contKey === '') return null;
207
+ if (entry !== null) entry[contKey] = parseScalar(body.slice(colon + 1).trim());
156
208
  i++;
157
209
  }
158
- entries.push(entry);
210
+ if (entry !== null) entries.push(entry);
211
+ itemIndex++;
159
212
  }
160
213
  return { value: entries, nextIndex: i };
161
214
  }
162
215
 
216
+ /**
217
+ * Splits a flow-collection interior on its TOP-LEVEL commas — the ones outside
218
+ * quotes and outside any nested `{}`/`[]`. A naive `split(',')` would cut
219
+ * `task: "a, b"` in half, which is the same class of first-separator mistake
220
+ * that produced the `{ id` key in the first place.
221
+ *
222
+ * @param {string} inner
223
+ * @returns {string[]}
224
+ */
225
+ function splitFlowSegments(inner) {
226
+ const segments = [];
227
+ let depth = 0;
228
+ let quote = null;
229
+ let current = '';
230
+ for (let i = 0; i < inner.length; i++) {
231
+ const ch = inner[i];
232
+ if (quote !== null) {
233
+ current += ch;
234
+ if (ch === '\\' && quote === '"') {
235
+ current += inner[++i] ?? '';
236
+ } else if (ch === quote) {
237
+ quote = null;
238
+ }
239
+ continue;
240
+ }
241
+ if (ch === '"' || ch === "'") quote = ch;
242
+ else if (ch === '{' || ch === '[') depth++;
243
+ else if (ch === '}' || ch === ']') depth--;
244
+ else if (ch === ',' && depth === 0) {
245
+ segments.push(current);
246
+ current = '';
247
+ continue;
248
+ }
249
+ current += ch;
250
+ }
251
+ segments.push(current);
252
+ return segments;
253
+ }
254
+
255
+ /**
256
+ * Parses a SINGLE-LINE YAML flow mapping (`{ k: v, k2: "v, 2" }`) into a plain
257
+ * object, reusing `parseScalar` for each value so a flow item and the equivalent
258
+ * block item yield byte-identical results.
259
+ *
260
+ * Returns `null` when `raw` is not a well-formed single-line flow mapping —
261
+ * unterminated, an empty segment, or a segment with no `:`. Callers report that
262
+ * as a dropped item; nothing here throws.
263
+ *
264
+ * KNOWN CEILING: a NESTED flow mapping value (`{ id: m-1, meta: { a: 1 } }`)
265
+ * keeps `{ a: 1 }` as a STRING rather than an object — this subset has no
266
+ * nested-mapping representation and the serializer has no way to emit one. Flat
267
+ * flow mappings are the whole observed population (hand-written `mission-status`
268
+ * / `docs-tasks` items). Revisit if a nested flow value ever appears in a real
269
+ * STATE.md.
270
+ *
271
+ * @param {string} raw
272
+ * @returns {object|null}
273
+ */
274
+ function parseFlowMapping(raw) {
275
+ const trimmed = raw.trim();
276
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
277
+ const inner = trimmed.slice(1, -1).trim();
278
+ const out = {};
279
+ if (inner === '') return out;
280
+ for (const segment of splitFlowSegments(inner)) {
281
+ const part = segment.trim();
282
+ if (part === '') return null;
283
+ const colon = part.indexOf(':');
284
+ if (colon === -1) return null;
285
+ const key = part.slice(0, colon).trim();
286
+ if (key === '') return null;
287
+ out[key] = parseScalar(part.slice(colon + 1).trim());
288
+ }
289
+ return out;
290
+ }
291
+
163
292
  function parseScalar(raw) {
164
293
  if (raw === '' || raw === 'null' || raw === '~') return null;
165
294
  if (raw === 'true') return true;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * STATE.md helpers (issue #184).
3
3
  *
4
- * Thin barrel — re-exports all 25 public symbols from the split submodules.
4
+ * Thin barrel — re-exports every public symbol of the split submodules.
5
5
  * Implementation lives in scripts/lib/state-md/*.mjs.
6
6
  *
7
7
  * @see scripts/lib/state-md/yaml-parser.mjs parseStateMd, serializeStateMd
8
8
  * @see scripts/lib/state-md/frontmatter-mutators.mjs touchUpdatedField, updateFrontmatterFields
9
9
  * @see scripts/lib/state-md/body-sections.mjs readCurrentTask, appendDeviation, markExpressPathComplete, appendWhatNotToRetry, readWhatNotToRetry, readOpenQuestions, appendOpenQuestion, markOpenQuestionAnswered
10
- * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, writeMissionStatus, setMissionStatus, readMissionStatus
10
+ * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
11
11
  * @see scripts/lib/state-md/recommendations.mjs parseRecommendations
12
12
  */
13
13
 
@@ -43,11 +43,21 @@ export {
43
43
 
44
44
  export {
45
45
  parseMissionStatus,
46
+ // The strict reader and the status vocabulary are part of the same public
47
+ // surface as `parseMissionStatus` (#1111) — a consumer that imports from this
48
+ // barrel (the documented entry point) reached neither until this re-export
49
+ // landed: a static `import { parseMissionStatusStrict } from '.../state-md.mjs'`
50
+ // failed at link time, and the `await import()` form — the one hooks and
51
+ // lazy loaders use — yielded `undefined` and failed only at the call.
52
+ parseMissionStatusStrict,
53
+ MISSION_STATUS_VALUES,
46
54
  writeMissionStatus,
47
55
  setMissionStatus,
48
56
  readMissionStatus,
49
57
  writeMissionStatusOnDisk,
50
58
  setMissionStatusOnDisk,
59
+ recoverFrontmatterMissionStatusDetailed,
60
+ setMissionStatusDetailed,
51
61
  } from './state-md/mission-status.mjs';
52
62
 
53
63
  export { parseRecommendations } from './state-md/recommendations.mjs';
@@ -220,6 +220,54 @@ export function filterRosterNames(names, rosterSet) {
220
220
  return deduped.slice(0, MAX_NAMES);
221
221
  }
222
222
 
223
+ /**
224
+ * Classify a single raw invocation name into the skills or commands bucket.
225
+ *
226
+ * The Skill tool surfaces slash-commands that have NO backing `skills/` directory
227
+ * under the same plugin-prefixed name form as real skills (e.g.
228
+ * `session-orchestrator:session` for `commands/session.md`). `roster.skills` holds
229
+ * PREFIXED names, `roster.commands` holds BARE ones — so a prefixed command name
230
+ * matches neither set and used to be bucketed to 'other' (GitLab #1189: commands
231
+ * was `[]` in 345/345 usage pings).
232
+ *
233
+ * Decision order (skill roster wins on a spelling collision such as
234
+ * `memory-cleanup`, which is both a skill dir and a command file):
235
+ * 1. name in rosterSkills → {kind:'skill', name} (verbatim)
236
+ * 2. name is PREFIXED and its bare form is in rosterCommands
237
+ * → {kind:'command', bare}
238
+ * 3. otherwise → {kind:'skill', name} (verbatim)
239
+ *
240
+ * Case 2 REQUIRES the plugin prefix (session-reviewer W2 finding, 2026-09-02).
241
+ * Without that requirement, a BARE third-party or personal skill name that
242
+ * happens to collide with one of our shipped command names (`test`, `close`,
243
+ * `go`, `release`, `portfolio` are all plausible foreign skill names) would be
244
+ * recorded as OUR command — a data-integrity defect, and the same rule made
245
+ * `memory-cleanup` reach BOTH buckets in one ping (bare → commands, prefixed →
246
+ * skills), double-counting a single surface. Requiring the prefix removes both:
247
+ * a bare arrival can only ever be a skill-path name, so an unknown one becomes
248
+ * 'other'. Cost: the one genuine bare `memory-cleanup` record is recorded as
249
+ * 'other' rather than as the command.
250
+ *
251
+ * Case 3 keeps foreign/third-party names on the SKILLS path only, where
252
+ * filterRosterNames projects them to the opaque token 'other' — an unclassified
253
+ * name is never duplicated into `commands`, preserving the privacy invariant.
254
+ *
255
+ * @param {unknown} name raw invocation name
256
+ * @param {Set<string>} rosterSkills prefixed shipped-skill names
257
+ * @param {Set<string>} rosterCommands bare shipped-command names
258
+ * @returns {{kind: 'skill'|'command', name: unknown}}
259
+ */
260
+ export function classifyInvocationName(name, rosterSkills, rosterCommands) {
261
+ const skills = rosterSkills instanceof Set ? rosterSkills : new Set();
262
+ const commands = rosterCommands instanceof Set ? rosterCommands : new Set();
263
+ if (typeof name !== 'string') return { kind: 'skill', name };
264
+ if (skills.has(name)) return { kind: 'skill', name };
265
+ if (!name.startsWith(SKILL_PREFIX)) return { kind: 'skill', name };
266
+ const bare = name.slice(SKILL_PREFIX.length);
267
+ if (commands.has(bare)) return { kind: 'command', name: bare };
268
+ return { kind: 'skill', name };
269
+ }
270
+
223
271
  // ---------------------------------------------------------------------------
224
272
  // Duration bucketing
225
273
  // ---------------------------------------------------------------------------
@@ -331,12 +379,12 @@ function distinctField(records, field) {
331
379
  * whitelist-clean but does NOT carry `anon_id` — the caller sets it via
332
380
  * ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
333
381
  *
334
- * skills are the distinct `.skill` values of `skillInvocations`; commands are the
335
- * distinct `.command` values of the same records (none in v1 skill-invocations
336
- * telemetry []; the field is honored so a future command-telemetry stream feeds
337
- * in without a signature change). Both are roster-filtered — off-roster names
338
- * become "other" — deduped, sorted, and capped. No frequencies are recorded (v1
339
- * decision).
382
+ * The distinct `.skill` and `.command` values of `skillInvocations` go through a
383
+ * single classifyInvocationName pass that routes each name to skills or commands
384
+ * (the `.command` field carries nothing today; it is honored so a future direct
385
+ * command-telemetry stream feeds in without a signature change). Both buckets are
386
+ * then roster-filtered — off-roster names become "other" — deduped, sorted, and
387
+ * capped. No frequencies are recorded (v1 decision).
340
388
  *
341
389
  * @param {{
342
390
  * sessionRecord: object,
@@ -362,8 +410,26 @@ export function buildUsagePing({
362
410
  const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
363
411
  const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
364
412
 
365
- const skillNames = distinctField(invocations, 'skill');
366
- const commandNames = distinctField(invocations, 'command');
413
+ // ONE classification pass over the union of both producer fields: today only
414
+ // `.skill` is written by hooks/skill-invocation-telemetry.mjs, `.command` is
415
+ // kept for the forward-compat producer documented above.
416
+ //
417
+ // `.command` records carry the BARE name, and classifyInvocationName requires
418
+ // the plugin prefix before it will route anything to the commands bucket (see
419
+ // its doc comment). So a `.command` name is normalized to the prefixed form
420
+ // first — the field itself is the "this is one of ours" signal that a bare
421
+ // `.skill` arrival lacks. Without this the forward-compat producer would be
422
+ // wired but dead: every record it writes would silently become 'other'.
423
+ const skillNames = [];
424
+ const commandNames = [];
425
+ const rawNames = [
426
+ ...distinctField(invocations, 'skill'),
427
+ ...distinctField(invocations, 'command').map((n) => (n.startsWith(SKILL_PREFIX) ? n : `${SKILL_PREFIX}${n}`)),
428
+ ];
429
+ for (const raw of rawNames) {
430
+ const { kind, name } = classifyInvocationName(raw, rosterSkills, rosterCommands);
431
+ (kind === 'command' ? commandNames : skillNames).push(name);
432
+ }
367
433
 
368
434
  return {
369
435
  record_kind: 'usage-ping',
@@ -39,6 +39,7 @@ import { ensureAnonId } from './anon-id.mjs';
39
39
  import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
40
40
  import { loadOwnerConfig } from '../owner-yaml.mjs';
41
41
  import { readJsonlFile } from '../io.mjs';
42
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
42
43
 
43
44
  // ---------------------------------------------------------------------------
44
45
  // Constants
@@ -93,15 +94,51 @@ function defaultSender({ env, timeoutMs }) {
93
94
  // Batch build
94
95
  // ---------------------------------------------------------------------------
95
96
 
97
+ /**
98
+ * The canonical (#1167-deduplicated) session record most recently WRITTEN to
99
+ * the ledger — ranked by `completed_at` (falling back to `started_at` when
100
+ * absent), the closest analogue to "the last line of the file" once the reader
101
+ * no longer trusts append order.
102
+ *
103
+ * `readCanonicalSessions` reorders its output to "first appearance of each
104
+ * surviving id" (see sessions-canonical.mjs's own docstring) — it is NOT
105
+ * append order — so a raw `records[records.length - 1]` (the pre-#1186 read)
106
+ * silently picks the WRONG session once a `session_id` duplicate or a
107
+ * `supersedes` collapse reshuffles the array. A record with neither timestamp
108
+ * sorts last and is never chosen over a dated one.
109
+ *
110
+ * @param {Array<object>} records — canonical session records.
111
+ * @returns {object|null}
112
+ */
113
+ function mostRecentSession(records) {
114
+ let best = null;
115
+ let bestTs = '';
116
+ for (const rec of records) {
117
+ if (!rec || typeof rec !== 'object') continue;
118
+ const ts =
119
+ typeof rec.completed_at === 'string' && rec.completed_at
120
+ ? rec.completed_at
121
+ : typeof rec.started_at === 'string'
122
+ ? rec.started_at
123
+ : '';
124
+ if (ts && ts > bestTs) {
125
+ best = rec;
126
+ bestTs = ts;
127
+ }
128
+ }
129
+ return best;
130
+ }
131
+
96
132
  /**
97
133
  * Build ONE whitelist-projected usage-ping record from the local JSONL streams.
98
134
  *
99
- * Reads `<metricsDir>/sessions.jsonl` + `<metricsDir>/skill-invocations.jsonl`
100
- * (metricsDir defaults to `<cwd>/.orchestrator/metrics`). The LAST sessions.jsonl
101
- * record defines the session window: skill-invocations whose `timestamp >=` its
102
- * `started_at` are included. When no session record exists, the ping falls back
103
- * to `session_type: 'other'`, `duration_bucket: '<15m'`, and the invocations of
104
- * the last 24 hours.
135
+ * Reads `<metricsDir>/sessions.jsonl` (via `readCanonicalSessions`, #1186 — the
136
+ * #1167 newest-wins-per-`session_id` / `supersedes` collapse) +
137
+ * `<metricsDir>/skill-invocations.jsonl`. The most-recently-written CANONICAL
138
+ * session record (`mostRecentSession`, above) defines the session window:
139
+ * skill-invocations whose `timestamp >=` its `started_at` are included. When no
140
+ * session record exists, the ping falls back to `session_type: 'other'`,
141
+ * `duration_bucket: '<15m'`, and the invocations of the last 24 hours.
105
142
  *
106
143
  * anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
107
144
  * telemetry.json record; a created/rotated ID is persisted via
@@ -137,10 +174,10 @@ export function buildBatch({
137
174
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
138
175
  const nowIso = now || new Date().toISOString();
139
176
 
140
- const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
177
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
141
178
  const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
142
179
 
143
- const sessionRecord = sessions.length > 0 ? sessions[sessions.length - 1] : null;
180
+ const sessionRecord = mostRecentSession(sessions);
144
181
 
145
182
  let windowInvocations;
146
183
  let sessionForPing;
@@ -287,25 +324,63 @@ export async function flush({
287
324
  // ---------------------------------------------------------------------------
288
325
 
289
326
  /**
290
- * Whether a daily-fallback flush is due: the queue is non-empty AND more than 24h
291
- * have passed since the last successful flush (a never-flushed queue with items
292
- * counts as due). Cheap — a single telemetry.json read + a queue stat. Never throws.
327
+ * Whether a daily-fallback flush is due. Two independent ways to be due, both
328
+ * requiring that more than 24h have passed since the last successful flush
329
+ * (never-flushed counts as infinitely stale):
330
+ *
331
+ * (a) RETRY — the offline queue is non-empty: earlier sends failed and the
332
+ * backlog deserves another attempt.
333
+ * (b) CATCH-UP — the queue is empty, but `<metricsDir>/sessions.jsonl` ends on
334
+ * a record whose `completed_at` is NEWER than `last_flush_at`: a session
335
+ * closed since the last successful send and produced no ping.
336
+ *
337
+ * (b) is the reason this predicate exists at all. While it was queue-only, the
338
+ * "daily fallback" could ONLY re-send what had already failed to send — it could
339
+ * never originate a ping. Measured 2026-08-23 (#1138): 588 session closes across
340
+ * 13 repos produced 82 ingest records (~14%), because the ONE writer of the
341
+ * queue is a failed `flush()`, and a `flush()` that never runs never fails.
342
+ *
343
+ * Cheap in the common case: the staleness gate is checked FIRST (one small
344
+ * telemetry.json read), so a host that flushed within the last 24h returns
345
+ * before touching either the queue or the session ledger.
346
+ *
347
+ * Never throws.
293
348
  *
294
349
  * @param {object} [opts]
295
350
  * @param {string} [opts.statePath] telemetry.json path override.
296
351
  * @param {string} [opts.queuePath] queue path override.
352
+ * @param {string} [opts.metricsDir] Metrics dir for the (b) catch-up probe
353
+ * (default `<cwd>/.orchestrator/metrics`).
297
354
  * @param {number} [opts.now] Reference time in epoch-ms (default Date.now()).
298
355
  * @returns {boolean}
299
356
  */
300
- export function shouldDailyFlush({ statePath, queuePath, now = Date.now() } = {}) {
357
+ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.now() } = {}) {
301
358
  try {
302
- const { count } = queueStats({ path: queuePath });
303
- if (count <= 0) return false;
304
-
359
+ // Staleness gate shared by BOTH disjuncts, so it runs first and short-
360
+ // circuits the two file probes below on every fresh host.
305
361
  const { record } = readTelemetryState({ path: statePath });
306
362
  const raw = record?.last_flush_at;
307
363
  const lastMs = typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? Date.parse(raw) : 0;
308
- return now - lastMs > DAILY_FLUSH_MS;
364
+ if (now - lastMs <= DAILY_FLUSH_MS) return false;
365
+
366
+ // (a) Retry an existing backlog.
367
+ const { count } = queueStats({ path: queuePath });
368
+ if (count > 0) return true;
369
+
370
+ // (b) Catch-up: a session completed after the last successful flush.
371
+ // Reuses buildBatch's reader (#1186: readCanonicalSessions +
372
+ // mostRecentSession) — same file, same #1167 dedupe, no second parser.
373
+ //
374
+ // Deliberate simplification (named ceiling): this parses the WHOLE
375
+ // sessions.jsonl to look at its last record. At the observed ledger size
376
+ // (271 records / 292 KB in this repo, 2026-08-23) that is sub-millisecond
377
+ // and it only runs once the 24h gate above has already passed. Revisit
378
+ // with a tail-read if any repo's sessions.jsonl passes ~10 MB.
379
+ const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
380
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
381
+ const last = mostRecentSession(sessions);
382
+ const completedMs = Date.parse(last?.completed_at);
383
+ return !Number.isNaN(completedMs) && completedMs > lastMs;
309
384
  } catch {
310
385
  return false;
311
386
  }
@@ -19,6 +19,7 @@ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
21
  import { findProjectRoot } from '../common.mjs';
22
+ import { stampEventSchemaVersion, validateEventRecord } from '../events-schema.mjs';
22
23
 
23
24
  /**
24
25
  * Path FRAGMENT joined against a resolved repo root at write time — NOT a
@@ -58,11 +59,22 @@ export function emit(eventType, payload = {}, { repoRoot } = {}) {
58
59
  const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
59
60
  const dir = path.dirname(eventsPath);
60
61
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
- const record = {
62
+ // `stampEventSchemaVersion()` rather than an inline `schema_version:` —
63
+ // one stamper for the whole ledger (#1177). It is additive (stamps only
64
+ // when absent), so a payload that carries its own version still wins, and
65
+ // it is pure (no fs), so the sync write path below is unaffected.
66
+ const record = stampEventSchemaVersion({
62
67
  event: eventType,
63
68
  timestamp: new Date().toISOString(),
64
69
  ...payload,
65
- };
70
+ });
71
+ // Same schema contract as emitEvent() (#1177), enforced here too because
72
+ // this is the ONE remaining raw writer of events.jsonl. Kept synchronous on
73
+ // purpose — withTelemetry() wraps sync layout code; validateEventRecord() is
74
+ // pure (no fs), so conformance costs no async hop. An invalid record is
75
+ // DROPPED rather than written: telemetry is best-effort by contract, and a
76
+ // malformed line would outlive this process in the shared ledger.
77
+ if (!validateEventRecord(record).valid) return;
66
78
  appendFileSync(eventsPath, JSON.stringify(record) + '\n');
67
79
  } catch {
68
80
  // Best-effort — swallow all errors. Telemetry must not block layout.
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import yaml from 'js-yaml';
11
11
  import { ALLOWED_MODEL_ALIASES, MODEL_ID_RE } from '../agent-frontmatter.mjs';
12
12
  import { extractInitialFrontmatter } from './frontmatter-block.mjs';
13
+ import { parseFrontmatterDescription } from '../description-surface.mjs';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
 
@@ -136,6 +137,57 @@ function parseToolsValue(toolsVal) {
136
137
  return v.split(',').map((t) => t.trim()).filter(Boolean);
137
138
  }
138
139
 
140
+ /**
141
+ * Detect a `description:` whose YAML-PARSED value is a silently truncated
142
+ * PREFIX of what the file actually says (#1029).
143
+ *
144
+ * An unquoted (plain) YAML scalar ends at the first ` #`: everything from there
145
+ * on is a comment. The block still parses, so every other rule in this gate
146
+ * stays green while the agent picker receives a stub — `agents/eval-judge.md`
147
+ * shipped 51 of 1,180 characters (96% dropped, measured 2026-08-24) and nothing
148
+ * complained. This is the concrete case the Check-6 preamble describes in prose.
149
+ *
150
+ * Detected by COMPARISON, never by pattern: the parsed value must be a strict
151
+ * prefix of the raw scalar AND the dropped tail must open a comment. A
152
+ * legitimately quoted description whose text merely contains `#` parses in full
153
+ * (parsed === raw, no length gap) and is never flagged; a quoted description
154
+ * carrying YAML escapes diverges from its raw form and fails the prefix test.
155
+ *
156
+ * Raw extraction is delegated to `parseFrontmatterDescription` (the sibling
157
+ * measurement probe already owns it, block scalars and quote-stripping included)
158
+ * rather than re-derived from a fourth regex here.
159
+ *
160
+ * @param {string} content - the agent file's raw content
161
+ * @param {Record<string, unknown>} data - the parsed frontmatter mapping
162
+ * @param {string} frontmatter - the frontmatter block's inner text
163
+ * @returns {{ parsedLength: number, rawLength: number } | null} null when intact
164
+ */
165
+ function detectDescriptionTruncation(content, data, frontmatter) {
166
+ if (!hasField(frontmatter, 'description')) return null;
167
+
168
+ // A `description:` key whose WHOLE value is a comment (`description: #803 …`)
169
+ // parses to null — a 0-character description, the same defect at 100% loss.
170
+ const parsedValue =
171
+ typeof data?.description === 'string'
172
+ ? data.description
173
+ : data?.description === null || data?.description === undefined
174
+ ? ''
175
+ : null;
176
+ if (parsedValue === null) return null;
177
+
178
+ const rawField = parseFrontmatterDescription(content);
179
+ // Block scalars carry no comment semantics and are rejected outright by the
180
+ // inline-string rule below — not this rule's business.
181
+ if (!rawField || rawField.isBlockScalar) return null;
182
+
183
+ const raw = rawField.raw;
184
+ if (raw.length <= parsedValue.length) return null;
185
+ if (!raw.startsWith(parsedValue)) return null;
186
+ if (!/(^|\s)#/.test(raw.slice(parsedValue.length))) return null;
187
+
188
+ return { parsedLength: parsedValue.length, rawLength: raw.length };
189
+ }
190
+
139
191
  // ============================================================================
140
192
  // Check 6: Agent .md files have valid YAML frontmatter
141
193
  // ============================================================================
@@ -226,6 +278,20 @@ if (mdFiles.length === 0) {
226
278
  }
227
279
  parseable++;
228
280
 
281
+ // ------------------------------------------------------------------
282
+ // Prefix-truncation rule (#1029) — the ` #` case the preamble above names.
283
+ // Runs BEFORE the field rules: a description truncated to a stub still
284
+ // satisfies every one of them, so this is the only place the loss shows up.
285
+ // ------------------------------------------------------------------
286
+ const truncated = detectDescriptionTruncation(content, parsedFm.data, frontmatter);
287
+ if (truncated) {
288
+ fail(
289
+ `${agentName}: description is silently truncated by an unquoted ' #' — YAML parses ` +
290
+ `${truncated.parsedLength} of ${truncated.rawLength} chars and reads the rest as a comment; ` +
291
+ `quote the description (double-quoted, escaping any " inside) so the full text parses`,
292
+ );
293
+ }
294
+
229
295
  // ------------------------------------------------------------------
230
296
  // Required fields check
231
297
  // ------------------------------------------------------------------