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.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +57 -0
- package/README.md +55 -51
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/release.md +4 -4
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-coverage-green.mjs +100 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/events.mjs +3 -3
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/project-hygiene.mjs +182 -6
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-identity/own-session.mjs +24 -13
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +32 -11
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- 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
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
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
|
-
(
|
|
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.
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
*
|
|
835
|
-
* the
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
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
|
|
914
|
-
if (typeof
|
|
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
|
|
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, {
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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' |
|
|
38
|
-
* 'skill-invocations.jsonl' | '
|
|
39
|
-
* '
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
360
|
-
* guard converts any unexpected error into an all-run plan so the
|
|
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]
|
|
368
|
-
*
|
|
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
|
|
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
|
-
|
|
375
|
+
mkRetired('3.6.5'),
|
|
378
376
|
decideSkillJudge({ repoRoot, cfg, sessionId }),
|
|
379
|
-
|
|
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
|
|
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
|
}
|