session-orchestrator 3.17.0 → 3.19.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 (187) 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/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -187,6 +187,7 @@ const ALLOWED_EMOJI_CODEPOINTS = new Set([
187
187
  0x2139, // ℹ
188
188
  0x2194, // ↔
189
189
  0x26a0, // ⚠
190
+ 0x26d4, // ⛔ (deny headline in emitDeny's operator systemMessage — #906)
190
191
  0x2705, // ✅
191
192
  0x274c, // ❌
192
193
  0x2b50, // ⭐
@@ -277,9 +277,17 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
277
277
  violations.push({
278
278
  rule: 'paths-frontmatter',
279
279
  severity: 'error',
280
+ // NOTE (#795 / corrected 2026-07-25): the previous wording claimed rule-loader.mjs
281
+ // does not recognize `paths:` and that such a rule loads always-on. Both are false
282
+ // since #795 — `rule-loader.mjs:275` accepts `paths` alongside `globs`, and `:313`
283
+ // treats it as a fallback alias (`globs:` wins silently when both are present), so a
284
+ // `paths:`-only rule IS glob-scoped. The probe itself stays: it enforces the canonical
285
+ // vendoring form, which is a convention gate, not a loader-compatibility gate. That
286
+ // intent survives #795 and is the subject of the #742 fleet canonicalisation sweep.
280
287
  message:
281
- `${relPath}: frontmatter declares a top-level 'paths:' key, which rule-loader.mjs does not recognize ` +
282
- `it is silently ignored and the rule loads as always-on instead of glob-scoped. Migrate to 'globs:'.`,
288
+ `${relPath}: frontmatter declares a top-level 'paths:' key. It is a recognized alias ` +
289
+ `for 'globs:' (issue #795), so the rule does load glob-scoped but 'globs:' is the ` +
290
+ `canonical form for vendored rules. Migrate to 'globs:' (see issue #742).`,
283
291
  line: lineWithinFrontmatter(fm.startLine, fm.body, pathsMatch.index),
284
292
  });
285
293
  }
@@ -240,8 +240,23 @@ export function buildArchiveFields({
240
240
  const finalType = VAULT_TYPE_ENUM.includes(base.type) ? base.type : type;
241
241
 
242
242
  // created: keep a valid existing date (original creation), else today.
243
- const created =
244
- typeof base.created === 'string' && ISO_DATE_RE.test(base.created) ? base.created : dateStr;
243
+ //
244
+ // js-yaml's default schema auto-resolves an unquoted ISO-8601-shaped scalar
245
+ // (the canonical `created: 2026-01-01` form) into a native `Date`, NOT a
246
+ // string — same quirk as moc-staleness-banner.mjs's `_readMocStalenessDays`
247
+ // (verified: `YAML.load('created: 2026-01-01').created instanceof Date`).
248
+ // Without this branch, an unquoted existing `created:` silently fails the
249
+ // `typeof === 'string'` check below and gets clobbered with today's date
250
+ // (#837). Accept both shapes; anything else (missing, number, array, an
251
+ // invalid Date, …) falls back to `dateStr` as before.
252
+ let created;
253
+ if (typeof base.created === 'string' && ISO_DATE_RE.test(base.created)) {
254
+ created = base.created;
255
+ } else if (base.created instanceof Date && !Number.isNaN(base.created.getTime())) {
256
+ created = base.created.toISOString().slice(0, 10);
257
+ } else {
258
+ created = dateStr;
259
+ }
245
260
 
246
261
  // title: explicit arg wins, else existing, else undefined (caller may derive).
247
262
  let finalTitle = title;
@@ -36,6 +36,14 @@ export function assertGlabExists(dieFn) {
36
36
 
37
37
  /**
38
38
  * Run a glab command, return { ok, stdout, stderr }.
39
+ *
40
+ * Host-pinning (#872): deliberately ambient — this module runs instance-wide
41
+ * queries (e.g. `glab repo list -g <group>`) that are not scoped to a single
42
+ * repo/project, so there is no single `-R`/`--repo` spec to pin. If a
43
+ * caller ever needs single-repo host-pinning here, use `--hostname` (the
44
+ * flag `glab api` and instance-wide subcommands accept), NOT `-R`/`--repo` —
45
+ * see `scripts/lib/vcs-repo-spec.mjs` for the `resolveRepoSpec` (`-R`) vs
46
+ * `resolveRepoHost` (`--hostname`) contract this repo already established.
39
47
  */
40
48
  export function glabRun(glabArgs) {
41
49
  vlog(`glab ${glabArgs.join(' ')}`);
@@ -10,6 +10,7 @@ import { join, resolve, basename } from 'node:path';
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { isRealSession } from '../session-schema/filters.mjs';
13
14
  import { resolveRepoNamespace } from './namespace.mjs';
14
15
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2 } from './render-learnings.mjs';
15
16
  import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3 } from './render-sessions.mjs';
@@ -464,6 +465,35 @@ export async function processSession(rawEntry, _lineNum, ctx) {
464
465
  session_id = session_id.slice(0, 240).replace(/-+$/, '');
465
466
  }
466
467
 
468
+ // #909 ABANDONED FILTER: a phantom stub backfilled from events.jsonl for a
469
+ // session that never ran /close is legitimate DATA in the ledger but not
470
+ // legitimate SIGNAL in a knowledge store — it records that a start happened,
471
+ // with 0 waves, 0 agents and synthesized fields. Mirroring it would add a note
472
+ // whose every number is a placeholder.
473
+ //
474
+ // The predicate is `isRealSession` from scripts/lib/session-schema/filters.mjs
475
+ // (fail-open: only an explicit `status: 'abandoned'` is filtered; the absent-
476
+ // status pre-#724 majority passes through). Reused rather than re-implemented
477
+ // so the ledger's definition of "real" lives in exactly one place.
478
+ //
479
+ // Placed BEFORE repoNs resolution and BEFORE the quality-gate render: skipping
480
+ // early avoids a git subprocess and a wasted render for a record we discard.
481
+ // This is deliberately REDUNDANT with the renderer's status mapping (#909,
482
+ // render-sessions.mjs) — see that module's header. The filter removes one
483
+ // status from the vault; the mapping keeps every OTHER status honest, and
484
+ // guards the generators' other entry point (the render.mjs barrel).
485
+ if (!isRealSession(entry)) {
486
+ emitAction({
487
+ action: 'skipped-abandoned',
488
+ path: null,
489
+ kind,
490
+ id: session_id,
491
+ vaultDir,
492
+ meta: { reason: `status:${entry?.status}` },
493
+ });
494
+ return;
495
+ }
496
+
467
497
  // #732: resolve the leak-guarded repo namespace ONCE per session, BEFORE the
468
498
  // quality gate, so both the write path (targetDir, below) AND the rendered
469
499
  // frontmatter (`source-repo`) use the SAME sanitised / pseudonym-mapped value.
@@ -18,6 +18,216 @@ import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
18
18
 
19
19
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
20
20
 
21
+ /**
22
+ * Placeholders for an ABSENT optional field (session-reviewer M1).
23
+ *
24
+ * These are the tokens the v2/v3 generators already use — `'?'` inside a wave
25
+ * table cell (generateSessionNoteV2's wave rows), `'n/a'` in a prose bullet
26
+ * (both v2 and v3). The v1 generator predates both guards and interpolated the
27
+ * same optional fields raw, so any record lacking one wrote the literal string
28
+ * `undefined` into a vault note a human reads.
29
+ *
30
+ * ── ABSENT IS NOT ZERO ─────────────────────────────────────────────────────
31
+ *
32
+ * Every use site must apply these with `??`, NEVER `||`. A wave that genuinely
33
+ * changed 0 files, or dispatched 0 agents, has a MEASURED zero and must render
34
+ * `0`; only a field the producer never wrote is unknown. `||` collapses that
35
+ * distinction (`0 || '?'` → `'?'`, asserting "unknown" about a real
36
+ * measurement); `??` preserves it (`0 ?? '?'` → `0`).
37
+ */
38
+ const MISSING_CELL = '?';
39
+ const MISSING_VALUE = 'n/a';
40
+
41
+ /**
42
+ * Render the `**Duration:**` bullet value for all three generators (#969 LOW-1).
43
+ *
44
+ * All three sites used to read `Math.round((duration_seconds ?? 0) / 60)`, three
45
+ * lines that violated the banner directly above them: neither `duration_minutes`
46
+ * nor `duration_seconds` is in ANY of the `RENDERABLE_SESSION_FIELDS_*` lists,
47
+ * so absence is reachable, and a record lacking both rendered `Duration: 0m` —
48
+ * a claimed MEASURED zero, contradicted on the same line by its own
49
+ * `started_at → completed_at` span, and sitting beside correctly-rendered `n/a`s.
50
+ * "0m" reads as a verified fact; that is the more damaging direction.
51
+ *
52
+ * The whole chain is `??`, never `||`, so a genuinely measured zero survives:
53
+ * `duration_minutes: 0` renders `0m`, and so does `duration_seconds: 0`
54
+ * (`Math.round(0 / 60)` is `0`, and `0 ?? x` is `0`). Only a field the producer
55
+ * never wrote becomes `MISSING_VALUE`. The suffix is applied HERE rather than at
56
+ * the call sites, because `${MISSING_VALUE}m` would render `n/am`.
57
+ *
58
+ * @param {unknown} durationSeconds — v1/v2/v3 `duration_seconds`.
59
+ * @param {unknown} [durationMinutes] — v3 `duration_minutes`; WINS when both are
60
+ * present, preserving v3's original precedence.
61
+ * @returns {string} e.g. `'42m'`, `'0m'`, or `'n/a'`.
62
+ */
63
+ function renderDuration(durationSeconds, durationMinutes) {
64
+ const fromSeconds =
65
+ durationSeconds === undefined || durationSeconds === null
66
+ ? undefined
67
+ : Math.round(durationSeconds / 60);
68
+ const minutes = durationMinutes ?? fromSeconds;
69
+ return minutes === undefined || minutes === null ? MISSING_VALUE : `${minutes}m`;
70
+ }
71
+
72
+ /**
73
+ * ── RENDERABLE ⊋ SCHEMA-VALID (#964) ───────────────────────────────────────
74
+ *
75
+ * Each generator gates on its own required-field list. Before #964 all three
76
+ * lists were function-local `const`s with no stated relationship to
77
+ * `REQUIRED_FIELDS` (scripts/lib/session-schema/constants.mjs) — the write-path
78
+ * schema — which made five independent notions of "a valid session record" in
79
+ * one codebase, drifting silently. They are lifted here, exported, and pinned
80
+ * against `REQUIRED_FIELDS` by a mechanical superset test in
81
+ * tests/lib/vault-mirror/render-sessions.test.mjs.
82
+ *
83
+ * The relationship is deliberately asymmetric, not a duplicate:
84
+ *
85
+ * `REQUIRED_FIELDS` — "this record is well-formed". The writer
86
+ * (scripts/emit-session.mjs) REFUSES a record that fails it.
87
+ * these lists — "this record renders into a note a human can read".
88
+ * vault-mirror SKIPS a record that fails them; the record stays valid.
89
+ *
90
+ * Collapsing the two would destroy that distinction. Hence `effectiveness`:
91
+ * optional to the writer (a record lacking it is fine), required by the v1
92
+ * renderer (a note reading `planned=n/a, completed=n/a, carryover=n/a,
93
+ * rate=n/a` is worse than no note — indistinguishable from a real session whose
94
+ * metrics happened to be absent).
95
+ *
96
+ * The predicate here is VALUE presence (`null`/`undefined` both fail); the
97
+ * validator's is KEY presence. See `_validateRequiredFields` for why.
98
+ */
99
+
100
+ /**
101
+ * v1 — the only generator reachable from a write-path-valid record (measured:
102
+ * 205/205 live ledger records at HEAD 730ee9d route v1). Its list is
103
+ * `REQUIRED_FIELDS` plus `effectiveness`, i.e. a strict superset.
104
+ */
105
+ export const RENDERABLE_SESSION_FIELDS_V1 = Object.freeze([
106
+ 'session_id',
107
+ 'session_type',
108
+ 'started_at',
109
+ 'completed_at',
110
+ 'total_waves',
111
+ 'total_agents',
112
+ 'total_files_changed',
113
+ 'agent_summary',
114
+ 'waves',
115
+ 'effectiveness',
116
+ ]);
117
+
118
+ /**
119
+ * v2 — legacy S69+ producer shape. Requires FEWER fields than `REQUIRED_FIELDS`
120
+ * (no `total_waves`/`total_agents`/`total_files_changed`/`agent_summary`)
121
+ * because it DERIVES every one of them from the wave array, and carries the
122
+ * file count as top-level `files_changed` — the alias `SESSION_KEY_ALIASES`
123
+ * maps to `total_files_changed` on read.
124
+ *
125
+ * That shortfall is an EARNED carve-out from the superset rule, not an
126
+ * oversight: `detectSessionSchema` routes to v2 only when
127
+ * `total_agents === undefined`, and `validateSession` rejects exactly that
128
+ * (missing key, or present-but-not-a-number). No record can satisfy both. The
129
+ * test asserts the disjointness rather than trusting this comment.
130
+ */
131
+ export const RENDERABLE_SESSION_FIELDS_V2 = Object.freeze([
132
+ 'session_id',
133
+ 'session_type',
134
+ 'started_at',
135
+ 'completed_at',
136
+ 'waves',
137
+ 'files_changed',
138
+ 'effectiveness',
139
+ ]);
140
+
141
+ /**
142
+ * v3 — coordinator-direct shape with SCALAR `waves`. Same earned carve-out as
143
+ * v2, from a sharper contradiction: `_validateWaves` throws unless
144
+ * `Array.isArray(entry.waves)`, `generateSessionNoteV3` throws unless
145
+ * `typeof entry.waves === 'number'`. A value cannot be both, so no record can
146
+ * satisfy both contracts — v3 is unreachable from the sanctioned writer by
147
+ * construction, and measurement agrees (0/205).
148
+ */
149
+ export const RENDERABLE_SESSION_FIELDS_V3 = Object.freeze([
150
+ 'session_id',
151
+ 'session_type',
152
+ 'started_at',
153
+ 'completed_at',
154
+ 'waves',
155
+ 'effectiveness',
156
+ ]);
157
+
158
+ /**
159
+ * Session-ledger status → vault-frontmatter status mapping (#909).
160
+ *
161
+ * ── WHY A MAPPING AND NOT A PASS-THROUGH ───────────────────────────────────
162
+ *
163
+ * The two value ranges are DISJOINT:
164
+ *
165
+ * `sessions.jsonl` `status` — `completed` | `abandoned`, or absent/null on
166
+ * every pre-#724 record (SESSION_STATUS, scripts/lib/session-schema/validator.mjs).
167
+ * vault frontmatter `status` — `draft` | `active` | `verified` | `archived` |
168
+ * `production` | `mvp` | `idea` (vaultNoteStatusSchema, skills/vault-sync/validator.mjs).
169
+ *
170
+ * Neither `completed` nor `abandoned` is a legal vault status. Emitting
171
+ * `session.status` verbatim would therefore write an off-schema frontmatter
172
+ * value and hard-fail `vault-sync`, which runs as a session-end Phase 1 gate.
173
+ *
174
+ * ── WHAT THIS REPLACES ─────────────────────────────────────────────────────
175
+ *
176
+ * All three generators previously hard-coded `status: verified` (frontmatter
177
+ * line + `status/verified` tag) and never read `session.status` at all — so
178
+ * every mirrored session claimed "verified" regardless of how it ended. A false
179
+ * `verified` in a knowledge store is worse than a missing note: it is a claim
180
+ * downstream readers cannot distinguish from a true one.
181
+ *
182
+ * ── THE MAPPING ────────────────────────────────────────────────────────────
183
+ *
184
+ * completed → verified (closed through the full /close flow incl. gates)
185
+ * abandoned → draft (phantom stub synthesized by the SessionEnd
186
+ * backfill from events.jsonl; incomplete fields,
187
+ * never gate-verified — `draft` is the honest
188
+ * "unfinished" value)
189
+ * absent/null → verified (pre-#724 records predate the field; per
190
+ * filters.mjs's fail-open contract these are
191
+ * genuine sessions, and this keeps the 200+
192
+ * already-mirrored notes byte-identical → no
193
+ * re-write churn on the next mirror run)
194
+ * anything else→ draft (fail-safe: the ledger enum is additive-optional
195
+ * and has grown once already; an unmapped future
196
+ * value must never be able to emit an off-schema
197
+ * vault status, and must never silently claim
198
+ * `verified`)
199
+ */
200
+ const VAULT_STATUS_BY_SESSION_STATUS = Object.freeze({
201
+ completed: 'verified',
202
+ abandoned: 'draft',
203
+ });
204
+
205
+ /** Vault status for a record that carries no `status` field (pre-#724). */
206
+ const VAULT_STATUS_WHEN_ABSENT = 'verified';
207
+
208
+ /** Vault status for a ledger value outside the known enum (fail-safe). */
209
+ const VAULT_STATUS_FALLBACK = 'draft';
210
+
211
+ /**
212
+ * Resolve the vault-frontmatter `status` for a session record.
213
+ *
214
+ * Guaranteed to return a member of vaultNoteStatusSchema for ANY input —
215
+ * including `null`, a non-object, or a hostile string such as `'constructor'`
216
+ * (hence `Object.hasOwn` rather than a bare property read, which would return
217
+ * an inherited `Object.prototype` member).
218
+ *
219
+ * @param {unknown} entry — a parsed sessions.jsonl record
220
+ * @returns {'verified'|'draft'}
221
+ */
222
+ export function vaultStatusForSession(entry) {
223
+ if (entry === null || typeof entry !== 'object') return VAULT_STATUS_WHEN_ABSENT;
224
+ const raw = entry.status;
225
+ if (raw === null || raw === undefined || raw === '') return VAULT_STATUS_WHEN_ABSENT;
226
+ return Object.hasOwn(VAULT_STATUS_BY_SESSION_STATUS, raw)
227
+ ? VAULT_STATUS_BY_SESSION_STATUS[raw]
228
+ : VAULT_STATUS_FALLBACK;
229
+ }
230
+
21
231
  /**
22
232
  * Frontmatter line emitter — skips emission when value is null/undefined/empty
23
233
  * to avoid template-literal coercion bugs (e.g. `platform: undefined` → "undefined").
@@ -34,11 +244,21 @@ function fmLine(key, value) {
34
244
  * v2 (S69+): files_changed (top-level), waves[{agents, agents_done, agents_partial, agents_failed, dispatch, duration_s}]
35
245
  * v3 (2026-05+): coordinator-direct records — `waves` is a SCALAR count and
36
246
  * `agents_dispatched` is a scalar; no per-wave array breakdown.
37
- * This is the shape session-end actually emits (#491), which
38
- * previously fell through to v1 validation and was rejected as
39
- * `skipped-invalid` (no vault session-note was ever written).
247
+ * Added for #491, when such records fell through to v1
248
+ * validation and were rejected as `skipped-invalid`.
40
249
  * v1 and v2 both carry `waves` as an ARRAY, so a numeric `waves` is the
41
250
  * unambiguous v3 discriminator.
251
+ *
252
+ * #964 — CORRECTION. This comment previously claimed v3 was "the shape
253
+ * session-end actually emits". It is not, and cannot be: `validateSession`
254
+ * requires `Array.isArray(waves)`, so a scalar-`waves` record is refused by
255
+ * `scripts/emit-session.mjs` before it can be written. Census over the live
256
+ * ledger at HEAD 730ee9d — 205 parseable records, post-`normalizeSessionEntry`:
257
+ * **v1 205, v2 0, v3 0**. Both v2 and v3 are read-path tolerances for foreign /
258
+ * legacy producer shapes, not targets of the sanctioned writer. v3's live
259
+ * reachability is therefore zero; see the follow-up note in the #964 report
260
+ * before treating it as dead code — deletion is a separate decision, and the
261
+ * `normalizeSessionEntry` alias path can still synthesize a scalar `waves`.
42
262
  */
43
263
  export function detectSessionSchema(entry) {
44
264
  if (!entry) return 'v1';
@@ -80,8 +300,7 @@ export function normalizeSessionEntry(entry) {
80
300
  }
81
301
 
82
302
  export function generateSessionNote(entry, options = {}) {
83
- const REQUIRED_SESSION_FIELDS = ['session_id', 'session_type', 'started_at', 'completed_at', 'total_waves', 'total_agents', 'total_files_changed', 'agent_summary', 'waves', 'effectiveness'];
84
- for (const field of REQUIRED_SESSION_FIELDS) {
303
+ for (const field of RENDERABLE_SESSION_FIELDS_V1) {
85
304
  if (entry[field] === null || entry[field] === undefined) {
86
305
  throw new Error(`vault-mirror: session entry missing required field '${field}' (session_id=${entry.session_id ?? '<no session_id>'})`);
87
306
  }
@@ -114,9 +333,19 @@ export function generateSessionNote(entry, options = {}) {
114
333
 
115
334
  const created = toDate(started_at);
116
335
  const updated = toDate(completed_at);
117
- const durationMin = Math.round((duration_seconds ?? 0) / 60);
118
- const { planned_issues, completed, carryover, emergent, completion_rate } = effectiveness;
119
- const ratePercent = Math.round(completion_rate * 100) + '%';
336
+ const durationLabel = renderDuration(duration_seconds);
337
+ // #M1: every `effectiveness` sub-field is OPTIONAL (validator.mjs
338
+ // `_validateOptionalFields` shape-checks the object, never its members), so a
339
+ // raw interpolation writes the literal string `undefined` into a note a human
340
+ // reads. `??` (never `||`) is load-bearing: a measured `0` must survive as `0`,
341
+ // only a genuinely absent value becomes the placeholder. The alias reads are
342
+ // ordered v1-field-first so a record that rendered a value before renders the
343
+ // exact same value now — this change can only ever replace `undefined`/`NaN`.
344
+ const { planned_issues, carryover, completion_rate } = effectiveness;
345
+ const completed = effectiveness.completed ?? effectiveness.completed_issues ?? MISSING_VALUE;
346
+ const emergent = effectiveness.emergent ?? effectiveness.unplanned_finds ?? MISSING_VALUE;
347
+ const ratePercent =
348
+ typeof completion_rate === 'number' ? Math.round(completion_rate * 100) + '%' : MISSING_VALUE;
120
349
  const { complete, partial, failed, spiral } = agent_summary;
121
350
 
122
351
  const titleValue = `Session ${created} — ${session_type}`;
@@ -128,13 +357,21 @@ export function generateSessionNote(entry, options = {}) {
128
357
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
129
358
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
130
359
  const noteId = slugifyIdSafe(session_id) ?? session_id;
131
- const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', 'verified'])}]`;
132
-
133
- // Build wave table rows
360
+ // #909: the real ledger status, mapped into the vault enum — never hard-coded.
361
+ const vaultStatus = vaultStatusForSession(entry);
362
+ const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
363
+
364
+ // Build wave table rows.
365
+ //
366
+ // #M1: only `wave` and `role` are schema-REQUIRED per wave (validator.mjs
367
+ // `_validateWaves`); `agent_count`, `files_changed` and `quality` are never
368
+ // validated, so all three are optional and were rendering as the literal
369
+ // string `undefined`. Guarded with the same `?? '?'` the v2 generator's wave
370
+ // rows already used — `??` so a measured `0` still renders `0`.
134
371
  const waveRows = waves
135
372
  .map(
136
373
  (w) =>
137
- `| ${w.wave} | ${w.role} | ${w.agent_count} | ${w.files_changed} | ${w.quality} |`,
374
+ `| ${w.wave} | ${w.role} | ${w.agent_count ?? MISSING_CELL} | ${w.files_changed ?? MISSING_CELL} | ${w.quality ?? MISSING_CELL} |`,
138
375
  )
139
376
  .join('\n');
140
377
 
@@ -152,7 +389,7 @@ export function generateSessionNote(entry, options = {}) {
152
389
  id: ${noteId}
153
390
  type: session
154
391
  title: ${title}
155
- status: verified
392
+ status: ${vaultStatus}
156
393
  created: ${created}
157
394
  updated: ${updated}
158
395
  tags: ${tags}
@@ -162,9 +399,9 @@ ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
162
399
  # Session ${session_id}
163
400
 
164
401
  - **Type:** ${session_type}${platformBullet}
165
- - **Duration:** ${durationMin}m (${started_at} → ${completed_at})
402
+ - **Duration:** ${durationLabel} (${started_at} → ${completed_at})
166
403
  - **Waves:** ${total_waves} · **Agents:** ${total_agents} · **Files changed:** ${total_files_changed}
167
- - **Effectiveness:** planned=${planned_issues}, completed=${completed}, carryover=${carryover}, emergent=${emergent}, rate=${ratePercent}
404
+ - **Effectiveness:** planned=${planned_issues ?? MISSING_VALUE}, completed=${completed}, carryover=${carryover ?? MISSING_VALUE}, emergent=${emergent}, rate=${ratePercent}
168
405
 
169
406
  ## Wave breakdown
170
407
 
@@ -179,8 +416,7 @@ ${waveRows}
179
416
  }
180
417
 
181
418
  export function generateSessionNoteV2(entry, options = {}) {
182
- const REQUIRED_SESSION_V2_FIELDS = ['session_id', 'session_type', 'started_at', 'completed_at', 'waves', 'files_changed', 'effectiveness'];
183
- for (const field of REQUIRED_SESSION_V2_FIELDS) {
419
+ for (const field of RENDERABLE_SESSION_FIELDS_V2) {
184
420
  if (entry[field] === null || entry[field] === undefined) {
185
421
  throw new Error(`vault-mirror: session entry missing required field '${field}' (session_id=${entry.session_id ?? '<no session_id>'})`);
186
422
  }
@@ -196,7 +432,7 @@ export function generateSessionNoteV2(entry, options = {}) {
196
432
 
197
433
  const created = toDate(started_at);
198
434
  const updated = toDate(completed_at);
199
- const durationMin = Math.round((duration_seconds ?? 0) / 60);
435
+ const durationLabel = renderDuration(duration_seconds);
200
436
 
201
437
  // Derive v1-equivalent aggregates from v2 wave structure
202
438
  const totalWaves = waves.length;
@@ -217,7 +453,9 @@ export function generateSessionNoteV2(entry, options = {}) {
217
453
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
218
454
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
219
455
  const noteId = slugifyIdSafe(session_id) ?? session_id;
220
- const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', 'verified'])}]`;
456
+ // #909: the real ledger status, mapped into the vault enum — never hard-coded.
457
+ const vaultStatus = vaultStatusForSession(entry);
458
+ const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
221
459
 
222
460
  const waveRows = waves
223
461
  .map((w) => `| ${w.wave} | ${w.role} | ${w.agents ?? '?'} | ${w.dispatch ?? '?'} | ${w.duration_s ?? '?'}s | ${w.agents_done ?? 0}/${w.agents_partial ?? 0}/${w.agents_failed ?? 0} |`)
@@ -236,7 +474,7 @@ export function generateSessionNoteV2(entry, options = {}) {
236
474
  id: ${noteId}
237
475
  type: session
238
476
  title: ${title}
239
- status: verified
477
+ status: ${vaultStatus}
240
478
  created: ${created}
241
479
  updated: ${updated}
242
480
  tags: ${tags}
@@ -246,7 +484,7 @@ ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
246
484
  # Session ${session_id}
247
485
 
248
486
  - **Type:** ${session_type}${branchLine}
249
- - **Duration:** ${durationMin}m (${started_at} → ${completed_at})
487
+ - **Duration:** ${durationLabel} (${started_at} → ${completed_at})
250
488
  - **Waves:** ${totalWaves} · **Agents:** ${totalAgents} · **Files changed:** ${files_changed}
251
489
  - **Effectiveness:** planned=${planned_issues ?? 'n/a'}, carryover=${carryover}, rate=${ratePercent}
252
490
  - **Issues closed:** ${closedList}
@@ -274,8 +512,7 @@ ${notesBlock}`;
274
512
  * and reject this shape, so it gets its own renderer.
275
513
  */
276
514
  export function generateSessionNoteV3(entry, options = {}) {
277
- const REQUIRED_SESSION_V3_FIELDS = ['session_id', 'session_type', 'started_at', 'completed_at', 'waves', 'effectiveness'];
278
- for (const field of REQUIRED_SESSION_V3_FIELDS) {
515
+ for (const field of RENDERABLE_SESSION_FIELDS_V3) {
279
516
  if (entry[field] === null || entry[field] === undefined) {
280
517
  throw new Error(`vault-mirror: session entry missing required field '${field}' (session_id=${entry.session_id ?? '<no session_id>'})`);
281
518
  }
@@ -296,24 +533,42 @@ export function generateSessionNoteV3(entry, options = {}) {
296
533
 
297
534
  const created = toDate(started_at);
298
535
  const updated = toDate(completed_at);
299
- const durationMin = duration_minutes ?? Math.round((duration_seconds ?? 0) / 60);
300
-
536
+ const durationLabel = renderDuration(duration_seconds, duration_minutes);
537
+
538
+ // #968 — ABSENT IS NOT ZERO. Two sites here defaulted an absent value to `0`,
539
+ // claiming a measured zero the producer never wrote: `emergent` (`?? 0`) and
540
+ // the `agent_summary` destructuring defaults (`= 0`, over an `as` that itself
541
+ // falls back to `{}`). That was inconsistent with v3's OWN treatment of the
542
+ // neighbouring fields — `carryover` used `?? 'n/a'` one line above `emergent`'s
543
+ // `?? 0` — and it is the more damaging direction: "0 agents failed" reads as a
544
+ // verified fact, "n/a" reads as unknown. `??` (never `||`) is load-bearing at
545
+ // every site: a MEASURED 0 must still render `0`.
301
546
  const completionRate = effectiveness.completion_rate;
302
- const ratePercent = typeof completionRate === 'number' ? Math.round(completionRate * 100) + '%' : 'n/a';
303
- const completed = effectiveness.completed_issues ?? effectiveness.completed ?? 'n/a';
304
- const carryover = effectiveness.carryover ?? 'n/a';
305
- const emergent = effectiveness.unplanned_finds ?? effectiveness.emergent ?? 0;
547
+ const ratePercent =
548
+ typeof completionRate === 'number' ? Math.round(completionRate * 100) + '%' : MISSING_VALUE;
549
+ const completed = effectiveness.completed_issues ?? effectiveness.completed ?? MISSING_VALUE;
550
+ const carryover = effectiveness.carryover ?? MISSING_VALUE;
551
+ const emergent = effectiveness.unplanned_finds ?? effectiveness.emergent ?? MISSING_VALUE;
306
552
 
307
- const agentsValue = typeof agents_dispatched === 'number' ? agents_dispatched : 'n/a';
553
+ const agentsValue = typeof agents_dispatched === 'number' ? agents_dispatched : MISSING_VALUE;
308
554
  const as = agent_summary && typeof agent_summary === 'object' ? agent_summary : {};
309
- const { complete = 0, partial = 0, failed = 0, spiral = 0 } = as;
555
+ const complete = as.complete ?? MISSING_VALUE;
556
+ const partial = as.partial ?? MISSING_VALUE;
557
+ const failed = as.failed ?? MISSING_VALUE;
558
+ const spiral = as.spiral ?? MISSING_VALUE;
310
559
 
311
560
  const fmtIssues = (list) =>
312
561
  Array.isArray(list) && list.length ? list.map((i) => `#${i}`).join(', ') : '—';
313
562
  const closedList = fmtIssues(issues_closed);
314
563
  const createdList = fmtIssues(issues_created);
315
564
  const followList = fmtIssues(follow_ups_filed);
316
- const commitCount = Array.isArray(commits) ? commits.length : 0;
565
+ // #969 LOW-1: an ABSENT `commits` is "not recorded", an EMPTY one is
566
+ // "recorded, and there were none". `commits` is not in
567
+ // RENDERABLE_SESSION_FIELDS_V3, so absence is reachable — and the old `: 0`
568
+ // published the second answer to the first question. A present-but-empty
569
+ // array keeps rendering `0`: that IS a measured zero, exactly the value the
570
+ // ABSENT-IS-NOT-ZERO banner says must survive.
571
+ const commitCount = Array.isArray(commits) ? commits.length : MISSING_VALUE;
317
572
  const testsDelta =
318
573
  typeof tests_total_pre === 'number' && typeof tests_total_post === 'number'
319
574
  ? `${tests_total_pre} → ${tests_total_post}`
@@ -328,7 +583,9 @@ export function generateSessionNoteV3(entry, options = {}) {
328
583
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
329
584
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
330
585
  const noteId = slugifyIdSafe(session_id) ?? session_id;
331
- const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', 'verified'])}]`;
586
+ // #909: the real ledger status, mapped into the vault enum — never hard-coded.
587
+ const vaultStatus = vaultStatusForSession(entry);
588
+ const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
332
589
 
333
590
  const platformBullet = platform === null || platform === undefined || platform === '' ? '' : ` · **Platform:** ${platform}`;
334
591
  const branchLine = branch ? ` · **Branch:** ${branch}` : '';
@@ -342,7 +599,7 @@ export function generateSessionNoteV3(entry, options = {}) {
342
599
  id: ${noteId}
343
600
  type: session
344
601
  title: ${title}
345
- status: verified
602
+ status: ${vaultStatus}
346
603
  created: ${created}
347
604
  updated: ${updated}
348
605
  tags: ${tags}
@@ -352,9 +609,9 @@ ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
352
609
  # Session ${session_id}
353
610
 
354
611
  - **Type:** ${session_type}${branchLine}${platformBullet}
355
- - **Duration:** ${durationMin}m (${started_at} → ${completed_at})
612
+ - **Duration:** ${durationLabel} (${started_at} → ${completed_at})
356
613
  - **Waves:** ${waves} · **Agents:** ${agentsValue} · **Commits:** ${commitCount}
357
- - **Effectiveness:** planned=${planned_issues ?? 'n/a'}, completed=${completed}, carryover=${carryover}, emergent=${emergent}, rate=${ratePercent}
614
+ - **Effectiveness:** planned=${planned_issues ?? MISSING_VALUE}, completed=${completed}, carryover=${carryover}, emergent=${emergent}, rate=${ratePercent}
358
615
  - **Tests:** ${testsDelta}
359
616
  - **Issues closed:** ${closedList}
360
617
  - **Issues created:** ${createdList}