session-orchestrator 4.0.1 → 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 (145) 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 +57 -0
  9. package/README.md +55 -51
  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/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -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);
@@ -6,11 +6,23 @@
6
6
  * the close-out abort-attractor: six phases, each ~50 lines of coordinator prose,
7
7
  * that in the overwhelming majority of sessions do nothing (no proposals queued,
8
8
  * nothing expired, under cadence, judge off, reconcile off). This aggregator
9
- * computes — side-effect-free — WHICH of the six should actually run, so the
9
+ * computes — side-effect-free — WHICH of them should actually run, so the
10
10
  * coordinator loads only the detail procedure for the `run: true` phases and
11
11
  * emits a one-line `skippedReport` for the rest.
12
12
  *
13
- * Every one of the six phases already ships a mechanical fast-path in its own lib
13
+ * FOUR phases are still LIVE and decided here: 3.6.3, 3.6.4, 3.6.6, 3.6.8.
14
+ * TWO are RETIRED (2026-09-09) and always emit a `run: false` entry with
15
+ * `inputSource: 'retired'`: 3.6.5 Auto-Dream and 3.6.7 Auto-Dialectic. Both
16
+ * nudges are now computed once at SESSION-START by the `maintenance-due` probe
17
+ * (`scripts/lib/maintenance-due-banner.mjs`), which runs the same
18
+ * `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic` signals and
19
+ * recommends `/session housekeeping`. Measured across three consumer repos the
20
+ * close-time nudges fired (`orchestrator.dialectic.nudge_decided`,
21
+ * `decided: true`, sessions_since 23/8/6) and were never acted on — computing
22
+ * them at every close was pure cost. The plan SHAPE is unchanged (still six
23
+ * entries, same ids, same order) so every reader keeps working.
24
+ *
25
+ * Each of the four live phases already ships a mechanical fast-path in its own lib
14
26
  * (a config kill-switch, an `existsSync` short-circuit, or a cadence/trigger
15
27
  * decision). This module WRAPS those existing signals — it never re-implements
16
28
  * their logic. Config gates run FIRST as the cheap short-circuit (no disk touch),
@@ -34,9 +46,10 @@
34
46
  * @property {boolean} run - true → coordinator runs the detail procedure.
35
47
  * @property {string} reason - human-readable reason for the decision.
36
48
  * @property {string} inputSource - which signal drove the decision
37
- * ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' | 'auto-dream-signal' |
38
- * 'skill-invocations.jsonl' | 'auto-dialectic-signal' | 'reconcile-dry-run' |
39
- * 'learnings.jsonl' | 'probe-error').
49
+ * ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' |
50
+ * 'skill-invocations.jsonl' | 'reconcile-dry-run' | 'learnings.jsonl' |
51
+ * 'retired' | 'probe-error'). `'retired'` is emitted only by the two
52
+ * permanently-skipped phases 3.6.5 / 3.6.7 — no signal was consulted at all.
40
53
  * @property {string[]} [targets] - 3.6.8 only, RUN decisions only: the effective
41
54
  * reconcile write-targets (issue #1099). Additive — absent on every other phase.
42
55
  * @property {string|null} [baselineRoot] - 3.6.8 only, RUN decisions only: the
@@ -52,11 +65,38 @@ import path from 'node:path';
52
65
 
53
66
  import { collectProposals } from '../memory-proposals/collector.mjs';
54
67
  import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
55
- import { shouldDispatchAutoDream } from '../auto-dream.mjs';
56
- import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
57
68
  import { readSkillInvocations } from '../skill-invocations-schema.mjs';
58
69
  import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
59
- import { resolveMemoryDir } from '../memory-paths.mjs';
70
+
71
+ // NOTE (2026-09-09): `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic`
72
+ // are deliberately NOT imported here any more — 3.6.5 and 3.6.7 are retired and
73
+ // the two signals are computed once at session-start by the `maintenance-due`
74
+ // probe. Re-adding either import is the regression this module's test suite
75
+ // guards against.
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Shared path resolution
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Resolve the learnings store + archive sidecar paths for a repo.
83
+ *
84
+ * Exported because `session-end/tail-runner.mjs` (the APPLY counterpart to the
85
+ * 3.6.4 dry-run decision below) must sweep exactly the pair this decision
86
+ * probed. Two hand-written `path.join(repoRoot, '.orchestrator', 'metrics', …)`
87
+ * recipes are one rename away from the planner deciding on one store while the
88
+ * runner rewrites another.
89
+ *
90
+ * @param {string} repoRoot
91
+ * @returns {{filePath: string, archivePath: string}}
92
+ */
93
+ export function resolveLearningsPaths(repoRoot) {
94
+ const dir = path.join(repoRoot, '.orchestrator', 'metrics');
95
+ return {
96
+ filePath: path.join(dir, 'learnings.jsonl'),
97
+ archivePath: path.join(dir, 'learnings-archive.jsonl'),
98
+ };
99
+ }
60
100
 
61
101
  // ---------------------------------------------------------------------------
62
102
  // Decision constructors
@@ -92,6 +132,23 @@ function mkProbeError(phase, err) {
92
132
  return decision(phase, true, `probe-error: ${msg}`, 'probe-error');
93
133
  }
94
134
 
135
+ /** Phase ids retired on 2026-09-09 — kept in the plan, never decided. */
136
+ const RETIRED_PHASES = new Set(['3.6.5', '3.6.7']);
137
+
138
+ /** The reason string every retired phase carries, verbatim. */
139
+ const RETIRED_REASON = 'retired 2026-09-09 — replaced by session-start maintenance-due probe';
140
+
141
+ /**
142
+ * Permanent skip for a retired phase. No signal is read and no config key is
143
+ * consulted — not even a kill-switch, because there is nothing left to switch.
144
+ *
145
+ * @param {string} phase
146
+ * @returns {PhaseDecision}
147
+ */
148
+ function mkRetired(phase) {
149
+ return mkSkip(phase, RETIRED_REASON, 'retired');
150
+ }
151
+
95
152
  // ---------------------------------------------------------------------------
96
153
  // Config / platform helpers
97
154
  // ---------------------------------------------------------------------------
@@ -101,17 +158,6 @@ function isPersistenceOff(cfg) {
101
158
  return cfg && cfg.persistence === false;
102
159
  }
103
160
 
104
- /**
105
- * MEMORY.md (Auto-Dream signal source) lives under `~/.claude/projects/` — a
106
- * Claude Code-only path. Absent platform → treated as Claude Code (the common
107
- * coordinator context); any explicit non-Claude platform gates 3.6.5 off.
108
- */
109
- function isClaudePlatform(platform) {
110
- if (platform === undefined || platform === null || platform === '') return true;
111
- const p = String(platform).toLowerCase();
112
- return p === 'claude' || p === 'claude-code' || p === 'claudecode';
113
- }
114
-
115
161
  // ---------------------------------------------------------------------------
116
162
  // Per-phase deciders — each wraps an EXISTING signal helper, config-gate first.
117
163
  // ---------------------------------------------------------------------------
@@ -149,14 +195,8 @@ async function decideMemoryProposals({ repoRoot, cfg }) {
149
195
  async function decideExpiredSweep({ repoRoot }) {
150
196
  const phase = '3.6.4';
151
197
  try {
152
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
198
+ const { filePath, archivePath } = resolveLearningsPaths(repoRoot);
153
199
  if (!existsSync(filePath)) return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
154
- const archivePath = path.join(
155
- repoRoot,
156
- '.orchestrator',
157
- 'metrics',
158
- 'learnings-archive.jsonl',
159
- );
160
200
  const res = await sweepExpiredLearnings({ filePath, archivePath, dryRun: true });
161
201
  if (!res || res.archived === 0) {
162
202
  return mkSkip(phase, `nothing archive-eligible (scanned=${res?.scanned ?? 0})`, 'sweep-dry-run');
@@ -168,36 +208,6 @@ async function decideExpiredSweep({ repoRoot }) {
168
208
  }
169
209
  }
170
210
 
171
- /**
172
- * 3.6.5 Auto-Dream nudge (#502). Config gate (kill-switch threshold=0, platform)
173
- * → then `shouldDispatchAutoDream` (trigger:false branches → skip).
174
- */
175
- async function decideAutoDream({ repoRoot, cfg, platform, memoryDir }) {
176
- const phase = '3.6.5';
177
- try {
178
- const threshold = cfg?.['memory-cleanup-threshold'] ?? 5;
179
- if (threshold === 0) {
180
- return mkSkip(phase, 'kill-switch (memory-cleanup-threshold=0)', 'config-gate');
181
- }
182
- if (!isClaudePlatform(platform)) {
183
- return mkSkip(phase, 'non-Claude-Code platform (memory dir unavailable)', 'config-gate');
184
- }
185
- // #1071: same root as the `repoRoot` handed to shouldDispatchAutoDream below —
186
- // a cwd-derived memory dir made the two halves of this decision disagree.
187
- const dir = memoryDir ?? resolveMemoryDir(repoRoot);
188
- const dec = await shouldDispatchAutoDream({
189
- repoRoot,
190
- memoryDir: dir,
191
- threshold,
192
- softLimit: cfg?.['memory-cleanup-soft-limit'] ?? 180,
193
- });
194
- if (!dec.trigger) return mkSkip(phase, dec.reason, 'auto-dream-signal');
195
- return mkRun(phase, dec.reason, 'auto-dream-signal');
196
- } catch (err) {
197
- return mkProbeError(phase, err);
198
- }
199
- }
200
-
201
211
  /**
202
212
  * 3.6.6 Skill-Applied Judge (#645 L3). Config gate (judge default OFF,
203
213
  * persistence) → then the judged-set emptiness check via
@@ -240,25 +250,6 @@ async function decideSkillJudge({ repoRoot, cfg, sessionId }) {
240
250
  }
241
251
  }
242
252
 
243
- /**
244
- * 3.6.7 Auto-Dialectic nudge (#506). Config gate (persistence, kill-switch
245
- * cadence=0) → then `shouldDispatchAutoDialectic` (the reference implementation
246
- * of the no-new-input-since-last-run pattern via `.orchestrator/dialectic-last-run`).
247
- */
248
- async function decideAutoDialectic({ repoRoot, cfg }) {
249
- const phase = '3.6.7';
250
- try {
251
- if (isPersistenceOff(cfg)) return mkSkip(phase, 'persistence=false', 'config-gate');
252
- const cadence = cfg?.dialectic?.cadence ?? 5;
253
- if (cadence === 0) return mkSkip(phase, 'kill-switch (dialectic.cadence=0)', 'config-gate');
254
- const dec = await shouldDispatchAutoDialectic({ repoRoot, cadence });
255
- if (!dec.trigger) return mkSkip(phase, dec.reason, 'auto-dialectic-signal');
256
- return mkRun(phase, dec.reason, 'auto-dialectic-signal');
257
- } catch (err) {
258
- return mkProbeError(phase, err);
259
- }
260
- }
261
-
262
253
  /**
263
254
  * 3.6.8 Reconciliation Rule Proposals (#696). Config gate (persistence,
264
255
  * reconcile.enabled default OFF, learnings.jsonl present) → then `runReconcile`
@@ -354,37 +345,45 @@ export function buildSkippedReport(plan) {
354
345
  // ---------------------------------------------------------------------------
355
346
 
356
347
  /**
357
- * Compute the run/skip plan for the six session-end Phase 3.6.x tail phases.
348
+ * Compute the run/skip plan for the six session-end Phase 3.6.x tail phase ids
349
+ * — four decided from live signals, two permanently retired (see the module
350
+ * header). The plan keeps all six entries, in id order, so readers that index
351
+ * by phase id are unaffected by the retirement.
358
352
  *
359
- * NEVER throws — each phase probe fail-opens to `run: true`, and the top-level
360
- * guard converts any unexpected error into an all-run plan so the coordinator
361
- * runs the full tail rather than silently skipping it.
353
+ * NEVER throws — each live phase probe fail-opens to `run: true`, and the
354
+ * top-level guard converts any unexpected error into an all-run plan so the
355
+ * coordinator runs the full tail rather than silently skipping it. The two
356
+ * retired phases stay `run: false` even on that error path: fail-open exists to
357
+ * avoid LOSING a phase, and a retired phase has no procedure left to lose.
362
358
  *
363
359
  * @param {object} args
364
360
  * @param {string} args.repoRoot Absolute repo root.
365
361
  * @param {object} args.config Parsed Session Config object (from parse-config.mjs).
366
362
  * @param {string|null} [args.sessionId] Current session id (for the 3.6.6 judged-set filter).
367
- * @param {string} [args.platform] Platform id ('claude' | 'codex' | 'cursor' | …).
368
- * @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir(repoRoot)).
363
+ * @param {string} [args.platform] ACCEPTED AND IGNORED since the 3.6.5 retirement
364
+ * (2026-09-09) the platform gate existed only for the Auto-Dream memory dir.
365
+ * Kept in the signature because live callers still pass it.
366
+ * @param {string} [args.memoryDir] ACCEPTED AND IGNORED, same reason as `platform`.
369
367
  * @returns {Promise<TailPlan>}
370
368
  */
371
- export async function planTailPhases({ repoRoot, config, sessionId, platform, memoryDir } = {}) {
369
+ export async function planTailPhases({ repoRoot, config, sessionId } = {}) {
372
370
  const cfg = config ?? {};
373
371
  try {
374
372
  const plan = await Promise.all([
375
373
  decideMemoryProposals({ repoRoot, cfg }),
376
374
  decideExpiredSweep({ repoRoot }),
377
- decideAutoDream({ repoRoot, cfg, platform, memoryDir }),
375
+ mkRetired('3.6.5'),
378
376
  decideSkillJudge({ repoRoot, cfg, sessionId }),
379
- decideAutoDialectic({ repoRoot, cfg }),
377
+ mkRetired('3.6.7'),
380
378
  decideReconcile({ repoRoot, cfg }),
381
379
  ]);
382
380
  return { plan, skippedReport: buildSkippedReport(plan) };
383
381
  } catch (err) {
384
- // Top-level fail-open guard: run the full tail rather than lose it silently.
382
+ // Top-level fail-open guard: run the full LIVE tail rather than lose it
383
+ // silently. Retired phases are never resurrected by an error.
385
384
  const msg = err && err.message ? err.message : String(err);
386
385
  const plan = ['3.6.3', '3.6.4', '3.6.5', '3.6.6', '3.6.7', '3.6.8'].map((p) =>
387
- decision(p, true, `probe-error: ${msg}`, 'probe-error'),
386
+ RETIRED_PHASES.has(p) ? mkRetired(p) : decision(p, true, `probe-error: ${msg}`, 'probe-error'),
388
387
  );
389
388
  return { plan, skippedReport: buildSkippedReport(plan) };
390
389
  }