session-orchestrator 4.1.0 → 4.2.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 (137) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +34 -0
  9. package/README.md +8 -8
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/session.md +3 -2
  16. package/docs/README.md +4 -4
  17. package/docs/USER-GUIDE.md +115 -48
  18. package/docs/agent-authoring.md +2 -2
  19. package/docs/baseline.md +55 -1
  20. package/docs/ci-setup.md +1 -1
  21. package/docs/codex-setup.md +1 -0
  22. package/docs/components.md +2 -2
  23. package/docs/cursor-setup.md +1 -0
  24. package/docs/events-schema.md +4 -1
  25. package/docs/instruction-delivery.md +1 -1
  26. package/docs/memory-proposal-flow.md +3 -3
  27. package/docs/migration-v4.md +2 -2
  28. package/docs/owner-config-schema.md +74 -90
  29. package/docs/persona-panel.md +4 -4
  30. package/docs/pi-setup.md +1 -0
  31. package/docs/rule-authoring.md +13 -6
  32. package/docs/scope-collision-guard.md +2 -0
  33. package/docs/session-config-reference.md +55 -22
  34. package/docs/session-config-template.md +9 -5
  35. package/docs/vault-docs-architecture.md +4 -2
  36. package/hooks/_lib/hook-import-set.json +28 -3
  37. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks.json +1 -1
  40. package/hooks/pre-bash-issue-budget.mjs +123 -26
  41. package/hooks/subagent-telemetry.mjs +106 -20
  42. package/package.json +4 -4
  43. package/scripts/baseline-archetypes.mjs +28 -0
  44. package/scripts/lib/auto-dialectic.mjs +0 -68
  45. package/scripts/lib/baseline-archetypes.mjs +439 -0
  46. package/scripts/lib/build-live-signals.mjs +5 -6
  47. package/scripts/lib/config/issue-budget.mjs +68 -8
  48. package/scripts/lib/config/private-config-dir.mjs +3 -2
  49. package/scripts/lib/config/remote-hosts.mjs +2 -2
  50. package/scripts/lib/config-schema.mjs +79 -0
  51. package/scripts/lib/file-lock.mjs +47 -5
  52. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  53. package/scripts/lib/issue-budget.mjs +76 -3
  54. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  55. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  56. package/scripts/lib/owner-config.example.yaml +29 -46
  57. package/scripts/lib/owner-yaml.mjs +14 -13
  58. package/scripts/lib/quality-gate.mjs +13 -6
  59. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  60. package/scripts/lib/rules-sync.mjs +34 -4
  61. package/scripts/lib/session-close-backfill.mjs +182 -40
  62. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  63. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  64. package/scripts/lib/session-schema/constants.mjs +6 -0
  65. package/scripts/lib/session-schema/validator.mjs +20 -0
  66. package/scripts/lib/session-shape.mjs +558 -0
  67. package/scripts/lib/session-start-probes.mjs +10 -3
  68. package/scripts/lib/session-token-rollup.mjs +95 -10
  69. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  70. package/scripts/lib/state-md.mjs +1 -0
  71. package/scripts/lib/subagents-schema.mjs +77 -9
  72. package/scripts/lib/telemetry/pricing.mjs +197 -0
  73. package/scripts/lib/telemetry/sync.mjs +50 -1
  74. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  75. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  76. package/scripts/lib/vault-mirror/process.mjs +2 -1
  77. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  78. package/scripts/lib/wave-resource-gate.mjs +23 -27
  79. package/scripts/lib/wave-sizing.mjs +10 -3
  80. package/scripts/materialize-wave-scope.mjs +68 -14
  81. package/scripts/print-applicable-rules.mjs +7 -6
  82. package/scripts/print-learnings-index.mjs +3 -2
  83. package/scripts/session-shape.mjs +266 -0
  84. package/skills/_shared/config-reading.md +15 -9
  85. package/skills/_shared/private-capability-context.md +89 -0
  86. package/skills/bootstrap/SKILL.md +61 -13
  87. package/skills/bootstrap/_shared-template.md +99 -14
  88. package/skills/bootstrap/deep-template.md +36 -26
  89. package/skills/bootstrap/fast-template.md +44 -8
  90. package/skills/bootstrap/intensity-heuristic.md +10 -4
  91. package/skills/bootstrap/private-contract.md +119 -0
  92. package/skills/bootstrap/public-fallback.md +30 -18
  93. package/skills/bootstrap/standard-template.md +39 -24
  94. package/skills/discovery/probes-ui.md +1 -1
  95. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  96. package/skills/evolve/SKILL.md +2 -2
  97. package/skills/gitlab-ops/SKILL.md +3 -3
  98. package/skills/grill/SKILL.md +1 -1
  99. package/skills/memory-cleanup/SKILL.md +2 -2
  100. package/skills/plan/mode-new.md +9 -0
  101. package/skills/reconcile/SKILL.md +1 -1
  102. package/skills/session-end/SKILL.md +3 -2
  103. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  104. package/skills/session-end/phase-3-6-tail.md +23 -65
  105. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  106. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  107. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  108. package/skills/session-end/session-metrics-write.md +31 -12
  109. package/skills/session-plan/SKILL.md +56 -48
  110. package/skills/session-plan/wave-template.md +8 -15
  111. package/skills/session-start/SKILL.md +18 -2
  112. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  113. package/skills/session-start/phase-8-5-express-path.md +12 -9
  114. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  115. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  116. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  117. package/skills/test-runner/rubric-v1.md +2 -2
  118. package/skills/wave-executor/SKILL.md +42 -12
  119. package/skills/wave-executor/circuit-breaker.md +3 -1
  120. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  121. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  122. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  123. package/templates/nextjs-minimal/package.json +1 -1
  124. package/templates/node-minimal/package.json +1 -1
  125. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  126. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  127. package/scripts/lib/owner-config/coerce.mjs +0 -29
  128. package/scripts/lib/owner-config/constants.mjs +0 -21
  129. package/scripts/lib/owner-config/defaults.mjs +0 -50
  130. package/scripts/lib/owner-config/error.mjs +0 -19
  131. package/scripts/lib/owner-config/index.mjs +0 -13
  132. package/scripts/lib/owner-config/merge.mjs +0 -52
  133. package/scripts/lib/owner-config/validate.mjs +0 -259
  134. package/scripts/lib/owner-config-loader.mjs +0 -170
  135. package/scripts/lib/owner-config.mjs +0 -28
  136. package/scripts/lib/soul-resolve.mjs +0 -130
  137. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -61,6 +61,14 @@
61
61
  * of starts is a broken instrument to be re-aimed, not a policy to obey.
62
62
  */
63
63
 
64
+ // `session-shape.mjs` is the ONE exported `agents-per-wave` resolver; this
65
+ // module used to carry a private third copy. Import-graph note (this file is
66
+ // hook-reachable via `hooks/_lib/hook-import-set.json`): `session-shape.mjs`
67
+ // statically imports only `session-schema/constants.mjs`, a pure-data leaf, and
68
+ // defers `events.mjs` to a lazy `await import()` — so nothing runs at module
69
+ // load here that did not run before.
70
+ import { resolveAgentCap } from '../session-shape.mjs';
71
+
64
72
  // ---------------------------------------------------------------------------
65
73
  // Verdict precedence helper
66
74
  // ---------------------------------------------------------------------------
@@ -252,29 +260,19 @@ function concurrencySignal(snapshot, concWarn) {
252
260
  }
253
261
 
254
262
  /**
255
- * Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
256
- * when no cap should apply. Mirrors `resolveApwCap()` in
257
- * `../wave-resource-gate.mjs` — kept as a local pure helper here rather than
258
- * a cross-module import since both sites are ≤10 lines and evolve
259
- * independently per their own gate's options shape.
263
+ * The session type this module resolves `agents-per-wave` FOR: none.
260
264
  *
261
- * `evaluate()` has no session-mode input in scope, so the object shape
262
- * resolves to `cap.default` the documented HR-003 convention writes the
263
- * override as `<default> (mode: <higher-ceiling>)`, i.e. `default` is the
264
- * MORE restrictive of the pair, so this can only under-apply a looser
265
- * mode-specific ceiling, never let a heavy repo exceed its base cap.
265
+ * `evaluate()` has no session-mode input in scope, so it deliberately asks
266
+ * `resolveAgentCap` for no type and takes the `.default` fallback. The HR-003
267
+ * convention writes the override as `<default> (mode: <higher-ceiling>)`, i.e.
268
+ * `default` is the MORE restrictive of the pair under-applying a looser
269
+ * mode-specific ceiling is safe, while passing a guessed `'deep'` here would
270
+ * silently RAISE a heavy repo's ceiling to the deep override on a loaded host.
271
+ * Naming the absence is what keeps that from being written by accident.
266
272
  *
267
- * @param {number|{default: number, [mode: string]: number}|*} cap
268
- * @returns {number|null}
273
+ * @type {undefined}
269
274
  */
270
- function resolveAgentsPerWaveCap(cap) {
271
- if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
272
- if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
273
- const def = cap.default;
274
- return typeof def === 'number' && Number.isFinite(def) ? def : null;
275
- }
276
- return null;
277
- }
275
+ const MODE_BLIND_SESSION_TYPE = undefined;
278
276
 
279
277
  /**
280
278
  * Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
@@ -423,7 +421,7 @@ export function evaluate(snapshot, thresholds, options = {}) {
423
421
  // the live-probe signals already computed.
424
422
  // ---------------------------------------------------------------------------
425
423
  const { heavyRepo, agentsPerWave } = options;
426
- const resolvedApwCap = resolveAgentsPerWaveCap(agentsPerWave);
424
+ const resolvedApwCap = resolveAgentCap(agentsPerWave, MODE_BLIND_SESSION_TYPE);
427
425
  if (heavyRepo === true && resolvedApwCap !== null) {
428
426
  cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
429
427
  }
@@ -121,7 +121,7 @@ function listManifestCategories(indexContent) {
121
121
  * @param {string|null} explicitArchetype
122
122
  * @returns {{archetype: string|null, known: boolean}}
123
123
  */
124
- function resolveArchetype(repoRoot, explicitArchetype) {
124
+ export function resolveArchetype(repoRoot, explicitArchetype) {
125
125
  if (explicitArchetype) {
126
126
  return { archetype: explicitArchetype.trim().toLowerCase(), known: true };
127
127
  }
@@ -194,6 +194,12 @@ function escapeRegex(s) {
194
194
  * at least one concrete bullet entry. This keeps `/bootstrap --sync-rules`
195
195
  * ready for future opt-in categories without requiring CLI changes.
196
196
  *
197
+ * `requiredBasenames` optionally adds explicitly required manifest entries,
198
+ * regardless of category or archetype scope. The caller supplies this data;
199
+ * this synchronous writer performs no external lookup. Every requested name
200
+ * must resolve uniquely in the full manifest before ANY file is written.
201
+ * Source validation, provenance, local overrides and dry-run still apply.
202
+ *
197
203
  * Vendoring sanitizer (issue #1098): every source file that reaches the write
198
204
  * decision is additionally scanned by `scanVendoringLeaks()`, and its findings
199
205
  * are collected into the additive `sanitizer[]` array. This runs in
@@ -214,7 +220,8 @@ function escapeRegex(s) {
214
220
  * dryRun?: boolean,
215
221
  * validate?: boolean,
216
222
  * requireProvenance?: boolean,
217
- * archetype?: string|null
223
+ * archetype?: string|null,
224
+ * requiredBasenames?: string[]|null
218
225
  * }} opts
219
226
  * @returns {{
220
227
  * written: string[],
@@ -233,6 +240,7 @@ export function syncRules({
233
240
  validate = true,
234
241
  requireProvenance = true,
235
242
  archetype = null,
243
+ requiredBasenames = null,
236
244
  } = {}) {
237
245
  const written = [];
238
246
  const skipped = [];
@@ -268,13 +276,35 @@ export function syncRules({
268
276
  ? categories
269
277
  : listManifestCategories(indexContent);
270
278
  const entries = parseIndex(indexContent, selectedCategories);
279
+ const fullManifest = parseIndex(indexContent, listManifestCategories(indexContent));
280
+ const required = new Set();
281
+ if (requiredBasenames !== null) {
282
+ if (!Array.isArray(requiredBasenames)) {
283
+ errors.push({ file: '_index.md', reason: 'requiredBasenames must be an array of unique manifest basenames' });
284
+ } else {
285
+ for (const name of requiredBasenames) {
286
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\.md$/.test(name) || required.has(name)) {
287
+ errors.push({ file: '_index.md', reason: 'invalid or duplicate required basename' });
288
+ continue;
289
+ }
290
+ required.add(name);
291
+ const matching = fullManifest.filter((entry) => basename(entry.relPath) === name);
292
+ if (matching.length !== 1) {
293
+ errors.push({ file: name, reason: 'required basename must resolve uniquely in _index.md' });
294
+ } else if (!entries.some((entry) => entry.relPath === matching[0].relPath)) {
295
+ entries.push(matching[0]);
296
+ }
297
+ }
298
+ }
299
+ if (errors.length > 0) return { written, skipped, preserved, errors, warnings, sanitizer };
300
+ }
271
301
 
272
302
  // Resolvability for the See-Also sanitizer is judged against the FULL
273
303
  // manifest, not `selectedCategories`: an archetype-scoped rule is a
274
304
  // legitimate citation target in every repo whose archetype matches, so
275
305
  // narrowing this to the current selection would report false leaks.
276
306
  const manifestBasenames = new Set(
277
- parseIndex(indexContent, listManifestCategories(indexContent)).map((e) => basename(e.relPath)),
307
+ fullManifest.map((e) => basename(e.relPath)),
278
308
  );
279
309
 
280
310
  if (entries.length === 0) {
@@ -297,7 +327,7 @@ export function syncRules({
297
327
 
298
328
  // Archetype filter (issue #722 Epic A Wave 3) — evaluated before any
299
329
  // file IO, so a skip never triggers a spurious "source file not found".
300
- if (archetypes !== null) {
330
+ if (archetypes !== null && !required.has(basename(relPath))) {
301
331
  if (!resolvedArchetype.known) {
302
332
  skipped.push({ file: relPath, reason: 'archetype-unknown' });
303
333
  continue;
@@ -25,7 +25,8 @@
25
25
  * `'abandoned'` — the session itself claims to have finished normally).
26
26
  *
27
27
  * ── ID BRIDGE ────────────────────────────────────────────────────────────────
28
- * sessions.jsonl records are keyed by SEMANTIC ids (`main-2026-05-27-session-1`).
28
+ * Legacy sessions.jsonl records are keyed by semantic ids; native records
29
+ * use the harness UUID with a separate `semantic_session_id` label.
29
30
  * events.jsonl carries the harness UUID on `session.started` / `stop` / `ended`.
30
31
  * The bridge is the `orchestrator.session.lock.acquired` event, which is the
31
32
  * only record carrying BOTH `session_id` (UUID) and `semantic_session_id`.
@@ -62,6 +63,12 @@ import { validateSession as defaultValidateSession } from './session-schema/vali
62
63
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
63
64
  import { resolveStateMdPath as defaultResolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
64
65
  import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs';
66
+ import { canonicalizeSessions } from './sessions-canonical.mjs';
67
+ // Leaf constants module (no imports of its own) and ALREADY in the hook import
68
+ // set via session-schema/validator.mjs — importing it here adds no new file to
69
+ // the SessionStart/SessionEnd hook graph. The profile enum must not be
70
+ // re-literalled: `VALID_SESSION_PROFILES` is its SSOT (GitLab #1252).
71
+ import { VALID_SESSION_PROFILES } from './session-schema/constants.mjs';
65
72
 
66
73
  // ---------------------------------------------------------------------------
67
74
  // Constants
@@ -84,6 +91,16 @@ const UNMEASURED_SESSION_TYPE = 'unknown';
84
91
 
85
92
  const EVENT_STARTED = 'orchestrator.session.started';
86
93
  const EVENT_LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
94
+ /**
95
+ * `orchestrator.session.shape_resolved` (`scripts/lib/session-shape.mjs`) — the
96
+ * ONLY event emitted AFTER the operator picked a mode, so it is the only
97
+ * measurement of what this session actually was. `lock.acquired.mode` fires at
98
+ * SessionStart, BEFORE `/session <type>` is typed, which is why nearly every
99
+ * abandoned stub carried `_session_type_inferred: true`; and no other event
100
+ * carries `session_profile` at all, so an abandoned ultradeep run was
101
+ * indistinguishable from an abandoned deep one.
102
+ */
103
+ const EVENT_SHAPE_RESOLVED = 'orchestrator.session.shape_resolved';
87
104
  // Both names for one generation (GitLab #1234): `hooks/on-stop.mjs` now emits
88
105
  // `orchestrator.turn.stopped` as the canonical name and keeps the legacy
89
106
  // `orchestrator.session.stopped` (with `deprecated: true`) beside it until
@@ -178,7 +195,8 @@ function canonicalIso(value, fallbackMs) {
178
195
  */
179
196
  function collectSessionEvents(events, { sessionId, semanticSessionId }) {
180
197
  const uuids = new Set();
181
- if (isUuid(sessionId)) uuids.add(sessionId);
198
+ const nativeId = isUuid(sessionId) ? sessionId : null;
199
+ if (nativeId) uuids.add(nativeId);
182
200
 
183
201
  let mode = null;
184
202
  let semanticFromLock = null;
@@ -190,25 +208,72 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
190
208
  // SessionEnd hook had already recorded under its semantic id. Measured
191
209
  // 2026-09-02 @ c3ab480: 8 such duplicate pairs in sessions.jsonl.
192
210
  let semanticFromEvents = null;
211
+ // Plan-time shape measurement (see EVENT_SHAPE_RESOLVED). Latest wins — a
212
+ // session may re-resolve its shape, and the last resolution is the one it ran.
213
+ // "Latest" is only decidable for a record that CARRIES a parseable timestamp:
214
+ // an undated one has no place in the order, so it may never displace a dated
215
+ // reading (the earlier `Number.isNaN(ts) => ordered` inverted exactly that and
216
+ // let an undated record win over a later, well-dated one).
217
+ let shapeSessionType = null;
218
+ let shapeSessionProfile = null;
219
+ let shapeTs = null;
220
+ // True while the readings above come from an UNDATED record — kept only for
221
+ // lack of a dated one, and surfaced so the caller can mark it low-confidence.
222
+ let shapeUndated = false;
193
223
 
194
224
  // First pass — bridge the UUID set + carry mode + semantic id. lock.acquired
195
- // is the original bridge; session.ended is the #1167 addition.
225
+ // is the original bridge; session.ended is the #1167 addition;
226
+ // shape_resolved is the plan-time type/profile measurement.
196
227
  for (const ev of events) {
197
228
  const isLock = ev.event === EVENT_LOCK_ACQUIRED;
198
229
  const isEnded = ev.event === EVENT_ENDED && typeof ev.semantic_session_id === 'string';
199
- if (!isLock && !isEnded) continue;
200
- const matchesUuid = isUuid(sessionId) && ev.session_id === sessionId;
201
- const matchesSemantic =
230
+ const isShape = ev.event === EVENT_SHAPE_RESOLVED;
231
+ if (!isLock && !isEnded && !isShape) continue;
232
+ const matchesUuid = nativeId && ev.session_id === nativeId;
233
+ // A label can be reused. Once a native UUID is known, a same-label event
234
+ // from another UUID must not expand the session whose work we recover.
235
+ const matchesSemantic = !nativeId && (
202
236
  (semanticSessionId && ev.semantic_session_id === semanticSessionId) ||
203
- (!isUuid(sessionId) && sessionId && ev.semantic_session_id === sessionId);
237
+ (sessionId && ev.semantic_session_id === sessionId)
238
+ );
204
239
  if (!matchesUuid && !matchesSemantic) continue;
205
240
  if (typeof ev.session_id === 'string') uuids.add(ev.session_id);
206
241
  if (isLock) {
207
242
  if (typeof ev.mode === 'string') mode = ev.mode;
208
243
  if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
209
- } else {
244
+ } else if (isEnded) {
210
245
  semanticFromEvents = ev.semantic_session_id;
211
246
  }
247
+ if (isShape) {
248
+ const ts = typeof ev.timestamp === 'string' ? Date.parse(ev.timestamp) : NaN;
249
+ if (!Number.isNaN(ts)) {
250
+ // Dated record: ordinary latest-wins.
251
+ if (shapeTs === null || ts >= shapeTs) {
252
+ if (shapeUndated) {
253
+ // A dated record outranks an undated one unconditionally. The
254
+ // undated readings were never orderable, so they are DISCARDED
255
+ // rather than merged — otherwise a profile read off an undated
256
+ // record would survive into a dated win it never belonged to.
257
+ shapeSessionType = null;
258
+ shapeSessionProfile = null;
259
+ shapeUndated = false;
260
+ }
261
+ shapeTs = shapeTs === null ? ts : Math.max(shapeTs, ts);
262
+ if (typeof ev.session_type === 'string') shapeSessionType = ev.session_type;
263
+ // Absent is not empty: the emitter OMITS the key when there is no
264
+ // profile, so only a present string may overwrite a previous reading.
265
+ if (typeof ev.session_profile === 'string') shapeSessionProfile = ev.session_profile;
266
+ }
267
+ } else if (shapeTs === null && !shapeUndated) {
268
+ // Undated record: usable only while NO dated record has been seen, and
269
+ // never as a tie-breaker between two of them.
270
+ const hasType = typeof ev.session_type === 'string';
271
+ const hasProfile = typeof ev.session_profile === 'string';
272
+ if (hasType) shapeSessionType = ev.session_type;
273
+ if (hasProfile) shapeSessionProfile = ev.session_profile;
274
+ if (hasType || hasProfile) shapeUndated = true;
275
+ }
276
+ }
212
277
  }
213
278
 
214
279
  // Second pass — started + terminal timestamps from every matched UUID.
@@ -247,6 +312,9 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
247
312
  return {
248
313
  uuids,
249
314
  mode,
315
+ shapeSessionType,
316
+ shapeSessionProfile,
317
+ shapeUndated,
250
318
  semanticFromLock,
251
319
  semanticFromEvents,
252
320
  startedAt,
@@ -336,13 +404,37 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
336
404
  // Guard the same monotonic invariant as before: never earlier than started_at.
337
405
  const completedIso = new Date(Math.max(startedMs, completedMs)).toISOString();
338
406
 
407
+ // Precedence: the plan-time shape beats the lock's SessionStart `mode`.
408
+ // `lock.acquired` fires BEFORE the operator types `/session <type>`, so its
409
+ // mode is at best a carry-over from the previous session; `shape_resolved` is
410
+ // emitted the moment the confirmed mode became an execution plan, i.e. it is
411
+ // the only MEASUREMENT of what this session was. Any session that reached
412
+ // plan time is therefore no longer `_session_type_inferred`.
413
+ // An unknown value in either source is IGNORED, never written — the record
414
+ // then stays `unknown` + inferred rather than carrying an unvalidatable type.
339
415
  let sessionType = UNMEASURED_SESSION_TYPE;
340
416
  let inferred = true;
341
- if (gathered.mode && MEASURED_SESSION_MODES.has(gathered.mode)) {
417
+ if (gathered.shapeSessionType && MEASURED_SESSION_MODES.has(gathered.shapeSessionType)) {
418
+ sessionType = gathered.shapeSessionType;
419
+ // A shape record with a missing/unparseable timestamp is taken only for
420
+ // lack of a dated one, and it cannot be proven to be the LAST resolution —
421
+ // so the type is used but stays flagged `_session_type_inferred: true`.
422
+ inferred = gathered.shapeUndated === true;
423
+ } else if (gathered.mode && MEASURED_SESSION_MODES.has(gathered.mode)) {
342
424
  sessionType = gathered.mode;
343
425
  inferred = false;
344
426
  }
345
427
 
428
+ // `session_profile` — WRITTEN ONLY WHEN MEASURED. Absent is not empty: a
429
+ // `null`/`''` on the record would read as "measured, no profile", which is
430
+ // exactly the honesty defect the enum-plus-omission contract exists to avoid
431
+ // (VALID_SESSION_PROFILES, session-schema/constants.mjs).
432
+ const sessionProfile =
433
+ typeof gathered.shapeSessionProfile === 'string'
434
+ && VALID_SESSION_PROFILES.includes(gathered.shapeSessionProfile)
435
+ ? gathered.shapeSessionProfile
436
+ : null;
437
+
346
438
  const startedFound = typeof gathered.startedAt === 'string';
347
439
  const branchFound = typeof gathered.branch === 'string' && gathered.branch.length > 0;
348
440
 
@@ -375,6 +467,7 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
375
467
  _backfill_incomplete_fields: incomplete,
376
468
  };
377
469
  if (branchFound) record.branch = gathered.branch;
470
+ if (sessionProfile) record.session_profile = sessionProfile;
378
471
  if (inferred) {
379
472
  record._session_type_inferred = true;
380
473
  // GitLab #1234 — BACKFILLER HONESTY, half landed 2026-09-06.
@@ -452,6 +545,48 @@ function isBackfillStub(record) {
452
545
  return BACKFILL_STUB_STATUSES.has(record.status);
453
546
  }
454
547
 
548
+ /**
549
+ * Find the newest ledger record for one physical session. Shared by the close
550
+ * precheck and backfill dedupe; no file I/O or mutation.
551
+ *
552
+ * Native identity wins over the attribution label. A conflicting native UUID
553
+ * vetoes a label match. Legacy records without a native join remain readable;
554
+ * when both start times are known they must name the same instant.
555
+ *
556
+ * @param {object[]} records parsed JSONL records, in append order
557
+ * @param {{sessionId?: string|null, semanticSessionId?: string|null, startedAt?: string|null}} ids
558
+ * @returns {object|null} existing record, or null when no identity matches
559
+ */
560
+ export function findRecordedSession(records, { sessionId = null, semanticSessionId = null, startedAt = null } = {}) {
561
+ // Identity strength cannot resurrect an overwritten/superseded stub. Use
562
+ // the canonical reader, while retaining append order across surviving keys.
563
+ const canonical = new Set(canonicalizeSessions(records));
564
+ const nativeId = isUuid(sessionId) ? sessionId : isUuid(semanticSessionId) ? semanticSessionId : null;
565
+ const label = semanticSessionId || sessionId;
566
+ const startMs = typeof startedAt === 'string' ? Date.parse(startedAt) : NaN;
567
+ let legacyMatch = null;
568
+ for (let i = records.length - 1; i >= 0; i--) {
569
+ const record = records[i];
570
+ if (!canonical.has(record)) continue;
571
+ if (!record || typeof record !== 'object' || Array.isArray(record)
572
+ || typeof record.session_id !== 'string' || !record.session_id) continue;
573
+ const nativeKeys = [record.session_id, record.raw_session_id].filter(isUuid);
574
+ if (nativeId && nativeKeys.length > 0) {
575
+ if (nativeKeys.every((id) => id === nativeId)) return record;
576
+ continue;
577
+ }
578
+ if (!label || (record.session_id !== label && record.semantic_session_id !== label)) continue;
579
+ // A backfill's fallback timestamp is explicitly unmeasured; treating it as
580
+ // a conflicting start would defeat idempotence on the very next close.
581
+ const incompleteStart = Array.isArray(record._backfill_incomplete_fields)
582
+ && record._backfill_incomplete_fields.includes('started_at');
583
+ const recordStart = !incompleteStart && typeof record.started_at === 'string' ? Date.parse(record.started_at) : NaN;
584
+ if (Number.isFinite(startMs) && Number.isFinite(recordStart) && startMs !== recordStart) continue;
585
+ legacyMatch ??= record;
586
+ }
587
+ return legacyMatch;
588
+ }
589
+
455
590
  /**
456
591
  * Classify what sessions.jsonl already holds for this identity (#1068 AC3/AC4).
457
592
  *
@@ -469,23 +604,12 @@ function isBackfillStub(record) {
469
604
  *
470
605
  * @param {Function} readFileSync
471
606
  * @param {string} sessionsPath
472
- * @param {{recordId: string, sessionId: string|null}} ids
607
+ * @param {{recordId: string, sessionId: string|null, semanticSessionId?: string|null, startedAt?: string|null}} ids
473
608
  */
474
- function classifyExisting(readFileSync, sessionsPath, { recordId, sessionId }) {
609
+ function classifyExisting(readFileSync, sessionsPath, { recordId, sessionId, semanticSessionId = recordId, startedAt = null }) {
475
610
  const sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
476
- // Both keys count: the semantic record id, and — defensively — a prior record
477
- // keyed directly by the UUID.
478
- const uuidKey = isUuid(sessionId) ? sessionId : null;
479
- const byId = (id) =>
480
- id === null ? [] : sessionRecords.filter((r) => r && r.session_id === id);
481
- // Key preference is UNCHANGED from the pre-#1068 dedupe: the semantic
482
- // recordId wins whenever any record carries it, and the UUID key is only the
483
- // defensive fallback.
484
- const semanticMatches = byId(recordId);
485
- const matches = semanticMatches.length > 0 ? semanticMatches : byId(uuidKey);
486
- if (matches.length === 0) return { kind: 'absent' };
487
-
488
- const newest = matches[matches.length - 1];
611
+ const newest = findRecordedSession(sessionRecords, { sessionId, semanticSessionId, startedAt });
612
+ if (!newest) return { kind: 'absent' };
489
613
  if (isBackfillStub(newest)) {
490
614
  return { kind: 'stub', matchedId: newest.session_id, stubId: newest.session_id };
491
615
  }
@@ -650,6 +774,15 @@ export async function backfillAbandonedSession({
650
774
  if (dupe) return dupe;
651
775
  }
652
776
 
777
+ // Dedupe rejected any foreign native identity above. Do not now reuse
778
+ // that session's semantic key: canonical readers collapse by session_id.
779
+ // Keep the existing legacy key convention unless the key is occupied.
780
+ const semanticRecordId = recordId;
781
+ if (isUuid(sessionId) && recordId !== sessionId
782
+ && readJsonlSafe(readFileSync, sessionsPath).some((record) => record?.session_id === recordId)) {
783
+ recordId = sessionId;
784
+ }
785
+
653
786
  // -- Liveness guard — never overwrite a FOREIGN live lock, and never ----
654
787
  // record OUR OWN live lock as 'abandoned' (#863 defect 1). Before this
655
788
  // fix, the guard below only ever ran when `foreign` was true — the
@@ -720,6 +853,7 @@ export async function backfillAbandonedSession({
720
853
  nowMs,
721
854
  rawSessionId: isUuid(sessionId) ? sessionId : null,
722
855
  });
856
+ if (recordId !== semanticRecordId) record.semantic_session_id = semanticRecordId;
723
857
  let validated;
724
858
  try {
725
859
  validated = validateSession(record);
@@ -831,17 +965,12 @@ export async function backfillAbandonedSession({
831
965
  * as "otherwise 0, flagged in `_backfill_incomplete_fields`" — the same
832
966
  * contract the abandoned path already carries and the same reason it exists.
833
967
  *
834
- * CONSTRAINT specific to this path: STATE.md's `session` field is already
835
- * the SEMANTIC id, never the raw harness UUID — unlike
836
- * `backfillAbandonedSession` (which usually receives the UUID directly from
837
- * SessionEnd stdin), this function has no UUID to seed `collectSessionEvents`'s
838
- * `uuids` set with. Events therefore only surface here when a
839
- * `lock.acquired` breadcrumb bridges the UUID to this exact semantic id
840
- * (`ev.semantic_session_id === recordId`) — the SAME bridge condition
841
- * `backfillAbandonedSession`'s synthetic-id fallback exists to handle when
842
- * ABSENT. Without that bridge, `gathered` stays empty and the record still
843
- * validates (started_at/completed_at both fall back to `now`, flagged
844
- * incomplete) — degraded but never blocked.
968
+ * A native `session-id` in STATE.md seeds event correlation directly and
969
+ * keys the new record; `session` is retained as `semantic_session_id`.
970
+ * Legacy STATE.md without a native UUID keeps its semantic record key and
971
+ * needs a lock.acquired/session.ended bridge to recover UUID-scoped events.
972
+ * Without either identity route, timestamps fall back to `now` and are
973
+ * flagged incomplete rather than fabricated from STATE.md body prose.
845
974
  *
846
975
  * Never throws. Returns one of:
847
976
  * { action: 'backfilled', sessionId, record } — written to disk
@@ -910,31 +1039,43 @@ export async function backfillCompletedFromStateMd({
910
1039
  return { action: 'skipped-not-completed', status: stateStatus ?? null };
911
1040
  }
912
1041
 
913
- const recordId = parsed.frontmatter?.session;
914
- if (typeof recordId !== 'string' || recordId.length === 0) {
1042
+ const semanticSessionId = parsed.frontmatter?.session;
1043
+ if (typeof semanticSessionId !== 'string' || semanticSessionId.length === 0) {
915
1044
  return { action: 'skipped-no-session-id' };
916
1045
  }
1046
+ const stateSessionId = parsed.frontmatter?.['session-id'];
1047
+ const nativeId = isUuid(stateSessionId) ? stateSessionId : isUuid(semanticSessionId) ? semanticSessionId : null;
1048
+ let recordId = nativeId ?? semanticSessionId;
917
1049
 
918
1050
  // -- Dedupe, or SUPERSEDE a backfill stub (#1068 AC3) ---------------------
919
1051
  // This is the authoritative writer of the pair: STATE.md's own
920
1052
  // `status: completed` is the session's truth claim about itself, and it
921
- // arrives with an identity-complete key (the semantic id). When the only
1053
+ // arrives with the native UUID or legacy semantic key. When the only
922
1054
  // thing on file for that identity is a reconstructed `abandoned` stub,
923
1055
  // the stub is a measurement this record refutes — so we append the fuller
924
1056
  // record (carrying `supersedes: <stub id>`) instead of skipping. An
925
1057
  // authoritative record already on file still short-circuits exactly as
926
1058
  // before.
927
1059
  const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
928
- const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId: recordId });
1060
+ const existing = classifyExisting(readFileSync, sessionsPath, {
1061
+ recordId,
1062
+ sessionId: nativeId,
1063
+ semanticSessionId,
1064
+ startedAt: parsed.frontmatter?.started_at,
1065
+ });
929
1066
  if (existing.kind === 'canonical') {
930
1067
  return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
931
1068
  }
932
1069
  const supersedes = existing.kind === 'stub' ? existing.stubId : null;
1070
+ // Preserve a matched stub's key for its append-only replacement. Without
1071
+ // event timestamps or a raw join on a legacy stub, a new UUID key would
1072
+ // leave two canonical sessions: its supersedes proof is unattestable.
1073
+ if (supersedes) recordId = supersedes;
933
1074
 
934
1075
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
935
1076
  const eventsPath = path.join(repoRoot, ...EVENTS_REL);
936
1077
  const events = readJsonlSafe(readFileSync, eventsPath);
937
- const gathered = collectSessionEvents(events, { sessionId: null, semanticSessionId: recordId });
1078
+ const gathered = collectSessionEvents(events, { sessionId: nativeId, semanticSessionId });
938
1079
 
939
1080
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
940
1081
  const record = synthesizeRecord({
@@ -953,6 +1094,7 @@ export async function backfillCompletedFromStateMd({
953
1094
  // same fail-quiet posture as `isUuid(sessionId) ? sessionId : null`.
954
1095
  rawSessionId: gathered.uuids?.size === 1 ? [...gathered.uuids][0] : null,
955
1096
  });
1097
+ if (nativeId && semanticSessionId !== nativeId) record.semantic_session_id = semanticSessionId;
956
1098
  let validated;
957
1099
  try {
958
1100
  validated = validateSession(record);