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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -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
|
|
282
|
-
`
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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:** ${
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:** ${
|
|
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
|
|
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
|
|
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 =
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
const
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:** ${
|
|
612
|
+
- **Duration:** ${durationLabel} (${started_at} → ${completed_at})
|
|
356
613
|
- **Waves:** ${waves} · **Agents:** ${agentsValue} · **Commits:** ${commitCount}
|
|
357
|
-
- **Effectiveness:** planned=${planned_issues ??
|
|
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}
|