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.
- 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 +34 -0
- package/README.md +8 -8
- 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/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/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/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/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-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-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-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/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
|
@@ -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
|
-
*
|
|
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
|
|
262
|
-
*
|
|
263
|
-
* override as `<default> (mode: <higher-ceiling>)`, i.e.
|
|
264
|
-
* MORE restrictive of the pair
|
|
265
|
-
* mode-specific ceiling,
|
|
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
|
-
* @
|
|
268
|
-
* @returns {number|null}
|
|
273
|
+
* @type {undefined}
|
|
269
274
|
*/
|
|
270
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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);
|