session-orchestrator 3.23.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -85,6 +85,7 @@ import fs from 'node:fs';
|
|
|
85
85
|
import path from 'node:path';
|
|
86
86
|
import { appendSubagent } from '../scripts/lib/subagents-schema.mjs';
|
|
87
87
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
88
|
+
import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
88
89
|
|
|
89
90
|
// ---------------------------------------------------------------------------
|
|
90
91
|
// Constants
|
|
@@ -489,38 +490,22 @@ function extractTranscriptUsage(transcriptPath) {
|
|
|
489
490
|
* Derive the path of the subagent's OWN transcript from the parent transcript
|
|
490
491
|
* path the harness sends on stdin (#949).
|
|
491
492
|
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* Falling back to `transcript_path` IS the #949 defect: it makes every stop
|
|
501
|
-
* inherit the parent's running token totals, so the caller must record null
|
|
502
|
-
* instead (an honest absence).
|
|
503
|
-
*
|
|
504
|
-
* The agent_id is charset-restricted before it is interpolated into a path.
|
|
505
|
-
* Real ids are hex-ish tokens (e.g. `a60348a01ca982b4c`); anything else is
|
|
506
|
-
* rejected rather than sanitised, so no payload value can traverse out of the
|
|
507
|
-
* `subagents/` directory.
|
|
493
|
+
* Thin wrapper over the consolidated derivation (#1196) —
|
|
494
|
+
* `hooks/_lib/subagent-paths.mjs` `resolveSubagentSidecar()` — which now
|
|
495
|
+
* applies a `{1,64}` bound to `agentId` (STRICTER than this file's prior
|
|
496
|
+
* unbounded `+` charset check; see that module's header for the full
|
|
497
|
+
* divergence table). Returns null — never the parent path — when the
|
|
498
|
+
* derivation is not possible. Falling back to `transcript_path` IS the #949
|
|
499
|
+
* defect: it makes every stop inherit the parent's running token totals, so
|
|
500
|
+
* the caller must record null instead (an honest absence).
|
|
508
501
|
*
|
|
509
502
|
* @param {string|undefined|null} parentTranscriptPath — stdin `transcript_path`
|
|
510
503
|
* @param {string} agentId — the stopping agent's id
|
|
511
504
|
* @returns {string|null} absolute candidate path, or null when underivable
|
|
512
505
|
*/
|
|
513
506
|
function resolveSubagentTranscriptPath(parentTranscriptPath, agentId) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// 'unknown' is the no-usable-id fallback — it names no file.
|
|
517
|
-
if (agentId === 'unknown') return null;
|
|
518
|
-
|
|
519
|
-
const dir = path.dirname(parentTranscriptPath);
|
|
520
|
-
const base = path.basename(parentTranscriptPath).replace(/\.jsonl$/i, '');
|
|
521
|
-
if (!base || base === '.' || base === '..') return null;
|
|
522
|
-
|
|
523
|
-
return path.join(dir, base, 'subagents', `agent-${agentId}.jsonl`);
|
|
507
|
+
const sidecar = resolveSubagentSidecar({ transcriptPath: parentTranscriptPath, agentId });
|
|
508
|
+
return sidecar === null ? null : sidecar.transcript;
|
|
524
509
|
}
|
|
525
510
|
|
|
526
511
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
package/scripts/autopilot.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { selectMode } from './lib/mode-selector.mjs';
|
|
|
47
47
|
import { probe, evaluate, DEFAULT_RESOURCE_THRESHOLDS } from './lib/resource-probe.mjs';
|
|
48
48
|
import { detectPeers } from './lib/session-registry.mjs';
|
|
49
49
|
import { normalizeSession } from './lib/session-schema.mjs';
|
|
50
|
+
import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
|
|
50
51
|
|
|
51
52
|
// ---------------------------------------------------------------------------
|
|
52
53
|
// CLI-level flag extraction
|
|
@@ -174,17 +175,29 @@ const branch = detectBranch();
|
|
|
174
175
|
const SESSIONS_JSONL_PATH = resolve('.orchestrator/metrics/sessions.jsonl');
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
178
|
+
* Set of the DISTINCT session identities currently recorded in sessions.jsonl.
|
|
179
|
+
* Empty set when the file is missing or unreadable.
|
|
180
|
+
*
|
|
181
|
+
* Identities, not lines (#1167): the ledger is append-only, so one physical
|
|
182
|
+
* session can occupy two lines (an abandoned stub plus the authoritative
|
|
183
|
+
* record that supersedes it, or the systemic double-stub pair). The
|
|
184
|
+
* pre/post comparison below asks "did the session I just spawned record
|
|
185
|
+
* itself?", and a raw line count answers that question wrong in BOTH
|
|
186
|
+
* directions — a duplicate pair looks like two sessions, while a supersede
|
|
187
|
+
* append (+1 line, −1 stub) leaves a canonical COUNT unchanged. Comparing the
|
|
188
|
+
* id SETS is immune to both.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Set<string>}
|
|
179
191
|
*/
|
|
180
|
-
function
|
|
181
|
-
if (!existsSync(SESSIONS_JSONL_PATH)) return
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
function readSessionIds() {
|
|
193
|
+
if (!existsSync(SESSIONS_JSONL_PATH)) return new Set();
|
|
194
|
+
const ids = new Set();
|
|
195
|
+
for (const rec of readCanonicalSessions({ filePath: SESSIONS_JSONL_PATH })) {
|
|
196
|
+
if (typeof rec.session_id === 'string' && rec.session_id.length > 0) {
|
|
197
|
+
ids.add(rec.session_id);
|
|
198
|
+
}
|
|
187
199
|
}
|
|
200
|
+
return ids;
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
/**
|
|
@@ -249,7 +262,7 @@ async function modeSelector() {
|
|
|
249
262
|
* @returns {Promise<{session_id: string, agent_summary?: object, effectiveness?: object}>}
|
|
250
263
|
*/
|
|
251
264
|
async function sessionRunner({ mode, autopilotRunId }) {
|
|
252
|
-
const
|
|
265
|
+
const preIds = readSessionIds();
|
|
253
266
|
|
|
254
267
|
await new Promise((res, rej) => {
|
|
255
268
|
const childStdio = hasVerbose
|
|
@@ -281,8 +294,9 @@ async function sessionRunner({ mode, autopilotRunId }) {
|
|
|
281
294
|
});
|
|
282
295
|
});
|
|
283
296
|
|
|
284
|
-
const
|
|
285
|
-
|
|
297
|
+
const postIds = readSessionIds();
|
|
298
|
+
const appeared = [...postIds].some((id) => !preIds.has(id));
|
|
299
|
+
if (!appeared) {
|
|
286
300
|
throw new Error('no session record appended');
|
|
287
301
|
}
|
|
288
302
|
|
|
@@ -42,10 +42,12 @@ import { parseArgs } from 'node:util';
|
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
|
|
44
44
|
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
|
+
import { emitEvent } from './lib/events.mjs';
|
|
45
46
|
import { SO_PROJECT_DIR } from './lib/platform.mjs';
|
|
46
47
|
|
|
47
48
|
const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
|
|
48
49
|
const SESSION_STARTED = 'orchestrator.session.started';
|
|
50
|
+
const SESSION_ENDED = 'orchestrator.session.ended';
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Default cap on how many candidates may reach the (expensive) shared core in a
|
|
@@ -54,12 +56,31 @@ const SESSION_STARTED = 'orchestrator.session.started';
|
|
|
54
56
|
*/
|
|
55
57
|
export const SESSION_START_LIMIT = 25;
|
|
56
58
|
|
|
57
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Read a JSONL file into parsed objects; MISSING (ENOENT) → `[]` silently,
|
|
61
|
+
* UNREADABLE (EACCES/EISDIR/…) → `[]` with a stderr WARN (#1188); malformed
|
|
62
|
+
* lines skipped.
|
|
63
|
+
*/
|
|
58
64
|
function readJsonl(filePath) {
|
|
59
65
|
let raw;
|
|
60
66
|
try {
|
|
61
67
|
raw = fs.readFileSync(filePath, 'utf8');
|
|
62
|
-
} catch {
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// #1188 — ENOENT and EACCES/EISDIR are different facts: a missing ledger is
|
|
70
|
+
// the ordinary fresh-repo case; an UNREADABLE one previously read as "no
|
|
71
|
+
// records" and made every downstream count silently wrong. Same split as
|
|
72
|
+
// readLockDetailed (session-lock.mjs § absent vs unreadable).
|
|
73
|
+
if (!err || err.code !== 'ENOENT') {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`⚠ backfill-abandoned-sessions: cannot read ${filePath} ` +
|
|
76
|
+
`(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
|
|
77
|
+
'treating as EMPTY, counts below are floors\n',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// CEILING (BV-004): still [] rather than throw — this runs on the
|
|
81
|
+
// SessionStart path (SESSION_START_LIMIT), where a permissions fault must
|
|
82
|
+
// not crash the session start. REVISIT if the warn rate in events.jsonl
|
|
83
|
+
// shows masked corruption.
|
|
63
84
|
return [];
|
|
64
85
|
}
|
|
65
86
|
const out = [];
|
|
@@ -86,16 +107,22 @@ function readJsonl(filePath) {
|
|
|
86
107
|
export function planSessions({ repoRoot }) {
|
|
87
108
|
const events = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl'));
|
|
88
109
|
|
|
89
|
-
|
|
110
|
+
// Two independent UUID -> semantic bridges (#1167). `lock.acquired` is the
|
|
111
|
+
// original one, but a session that LOST the lock-acquire race never emits it;
|
|
112
|
+
// its `session.ended` event carries `semantic_session_id` all the same (since
|
|
113
|
+
// #1068 AC1) and was never read here. Without that second bridge the
|
|
114
|
+
// candidate resolved semantic=null and the shared core minted a SYNTHETIC id,
|
|
115
|
+
// writing a duplicate stub beside the record the SessionEnd hook had already
|
|
116
|
+
// written under the real semantic id (measured 2026-09-02 @ c3ab480: 8 pairs).
|
|
117
|
+
// lock.acquired keeps precedence — it is the older, mode-carrying attestation.
|
|
118
|
+
const semanticFromLock = new Map();
|
|
119
|
+
const semanticFromEnded = new Map();
|
|
90
120
|
for (const ev of events) {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
typeof ev.semantic_session_id === 'string'
|
|
95
|
-
) {
|
|
96
|
-
semanticByUuid.set(ev.session_id, ev.semantic_session_id);
|
|
97
|
-
}
|
|
121
|
+
if (typeof ev.session_id !== 'string' || typeof ev.semantic_session_id !== 'string') continue;
|
|
122
|
+
if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
|
|
123
|
+
else if (ev.event === SESSION_ENDED) semanticFromEnded.set(ev.session_id, ev.semantic_session_id);
|
|
98
124
|
}
|
|
125
|
+
const semanticByUuid = new Map([...semanticFromEnded, ...semanticFromLock]);
|
|
99
126
|
|
|
100
127
|
const seen = new Set();
|
|
101
128
|
const plan = [];
|
|
@@ -133,7 +160,14 @@ function readRecordedIds(repoRoot) {
|
|
|
133
160
|
const records = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl'));
|
|
134
161
|
const ids = new Set();
|
|
135
162
|
for (const r of records) {
|
|
136
|
-
if (r
|
|
163
|
+
if (!r) continue;
|
|
164
|
+
if (typeof r.session_id === 'string') ids.add(r.session_id);
|
|
165
|
+
// #1167 — a backfilled record now stamps the harness UUID it was
|
|
166
|
+
// reconstructed from. Indexing it lets a bare-UUID candidate be recognised
|
|
167
|
+
// as already-recorded even though the record itself is keyed semantically —
|
|
168
|
+
// the join that was structurally impossible while the field was absent
|
|
169
|
+
// (0 of 286 records carried it, measured 2026-09-02 @ c3ab480).
|
|
170
|
+
if (typeof r.raw_session_id === 'string') ids.add(r.raw_session_id);
|
|
137
171
|
}
|
|
138
172
|
return ids;
|
|
139
173
|
}
|
|
@@ -238,6 +272,7 @@ export async function runMigration({
|
|
|
238
272
|
case 'backfilled':
|
|
239
273
|
summary.backfilled += 1;
|
|
240
274
|
if (res.deadByAge) summary.dead_by_age += 1;
|
|
275
|
+
await emitBackfillCompleted(repoRoot, item, res);
|
|
241
276
|
// Keep the pre-filter snapshot in step with what we just wrote, so a
|
|
242
277
|
// second candidate bridging to the SAME semantic id is skipped cheaply
|
|
243
278
|
// instead of re-entering the core (which would reach the same verdict).
|
|
@@ -263,6 +298,40 @@ export async function runMigration({
|
|
|
263
298
|
return summary;
|
|
264
299
|
}
|
|
265
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Emit `orchestrator.session.backfill_completed` for a record THIS path wrote
|
|
303
|
+
* (#1167). Mirrors the payload of `hooks/on-session-end.mjs::emitBackfillOutcome`
|
|
304
|
+
* so both writers are queryable with one filter.
|
|
305
|
+
*
|
|
306
|
+
* Until now the startup/CLI path wrote records SILENTLY: only the SessionEnd
|
|
307
|
+
* hook emitted the event, so nothing in the event stream distinguished "the
|
|
308
|
+
* backfill never ran" from "the backfill ran here". Best-effort by contract —
|
|
309
|
+
* observability must never fail a migration run.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} repoRoot
|
|
312
|
+
* @param {{sessionId: string, semanticSessionId: string|null}} item
|
|
313
|
+
* @param {{action: string, sessionId?: string, supersedes?: string, error?: string}} res
|
|
314
|
+
*/
|
|
315
|
+
async function emitBackfillCompleted(repoRoot, item, res) {
|
|
316
|
+
try {
|
|
317
|
+
await emitEvent(
|
|
318
|
+
'orchestrator.session.backfill_completed',
|
|
319
|
+
{
|
|
320
|
+
kind: 'abandoned',
|
|
321
|
+
action: typeof res?.action === 'string' ? res.action : 'unknown',
|
|
322
|
+
...(typeof item?.sessionId === 'string' ? { session_id: item.sessionId } : {}),
|
|
323
|
+
...(typeof item?.semanticSessionId === 'string'
|
|
324
|
+
? { semantic_session_id: item.semanticSessionId }
|
|
325
|
+
: {}),
|
|
326
|
+
...(typeof res?.sessionId === 'string' ? { record_id: res.sessionId } : {}),
|
|
327
|
+
...(typeof res?.supersedes === 'string' ? { supersedes: res.supersedes } : {}),
|
|
328
|
+
...(typeof res?.error === 'string' ? { reason: res.error } : {}),
|
|
329
|
+
},
|
|
330
|
+
{ repoRoot },
|
|
331
|
+
);
|
|
332
|
+
} catch { /* observability is best-effort */ }
|
|
333
|
+
}
|
|
334
|
+
|
|
266
335
|
/**
|
|
267
336
|
* SessionStart entry point (#926) — decouple the backfill from the /close path.
|
|
268
337
|
*
|
package/scripts/emit-event.mjs
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Exit codes (per .claude/rules/cli-design.md):
|
|
24
24
|
* 0 — success (event emitted)
|
|
25
|
-
* 1 — user/input error (missing --type, malformed --payload JSON, non-object
|
|
25
|
+
* 1 — user/input error (missing --type, malformed --payload JSON, non-object
|
|
26
|
+
* payload, or a record rejected by validateEventRecord — #1177)
|
|
26
27
|
* 2 — system error (write failure, unexpected internal error)
|
|
27
28
|
*
|
|
28
29
|
* Data → stdout (only with --json). Diagnostics → stderr (always). Related: #611.
|
|
@@ -49,7 +50,8 @@ Flags:
|
|
|
49
50
|
|
|
50
51
|
Exit codes:
|
|
51
52
|
0 — success
|
|
52
|
-
1 — user/input error (missing --type, malformed --payload, non-object payload
|
|
53
|
+
1 — user/input error (missing --type, malformed --payload, non-object payload,
|
|
54
|
+
or an event record rejected by the events schema)
|
|
53
55
|
2 — system error (write failure / internal error)
|
|
54
56
|
`;
|
|
55
57
|
|
|
@@ -147,6 +149,12 @@ try {
|
|
|
147
149
|
try {
|
|
148
150
|
await emitEvent(type, payload, filePath ? { filePath } : {});
|
|
149
151
|
} catch (err) {
|
|
152
|
+
// A schema-validation rejection is a USER/INPUT error (bad --type or a
|
|
153
|
+
// payload key colliding with the schema) — exit 1, not 2. Nothing was
|
|
154
|
+
// written: emitEvent validates before it touches the filesystem (#1177).
|
|
155
|
+
if (err?.name === 'EventValidationError') {
|
|
156
|
+
fail(`invalid event: ${err.message}`, 1, jsonMode);
|
|
157
|
+
}
|
|
150
158
|
// Write/IO failures are system errors (exit 2).
|
|
151
159
|
fail(`failed to emit event: ${err.message}`, 2, jsonMode);
|
|
152
160
|
}
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
RECOMMENDED_MARKERS,
|
|
83
83
|
isRecommendedOption,
|
|
84
84
|
} from './schema.mjs';
|
|
85
|
+
import { scanFenceBlocks } from '../validate/markdown-fences.mjs';
|
|
85
86
|
|
|
86
87
|
// ---------------------------------------------------------------------------
|
|
87
88
|
// Korpus-Abgrenzung
|
|
@@ -330,42 +331,17 @@ function lineOfOffset(starts, offset) {
|
|
|
330
331
|
}
|
|
331
332
|
|
|
332
333
|
/**
|
|
333
|
-
* Zaun-
|
|
334
|
+
* Zerlegt eine Markdown-Datei in Code-Zaun-Blöcke. **Zeilenanfangs- UND
|
|
335
|
+
* -ende-verankert** (`{ wholeLine: true }`, gemeinsamer Zaun-Tracker
|
|
336
|
+
* `../validate/markdown-fences.mjs`, #1181) — ohne den Anker schließt
|
|
334
337
|
* `skills/discovery/SKILL.md:373` seinen eigenen Block mitten im Fragetext
|
|
335
338
|
* (Falle 1).
|
|
336
|
-
*/
|
|
337
|
-
export const FENCE_LINE_PATTERN = /^[ \t]*(`{3,}|~{3,})[ \t]*([^\s`~]*)[ \t]*$/u;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Zerlegt eine Markdown-Datei in Code-Zaun-Blöcke.
|
|
341
339
|
*
|
|
342
340
|
* @param {string} content
|
|
343
341
|
* @returns {Array<{openLine: number, closeLine: number, lang: string, bodyLines: string[], bodyStartLine: number}>}
|
|
344
342
|
*/
|
|
345
343
|
export function fencesOf(content) {
|
|
346
|
-
|
|
347
|
-
const fences = [];
|
|
348
|
-
let open = null;
|
|
349
|
-
for (let i = 0; i < lines.length; i++) {
|
|
350
|
-
const m = FENCE_LINE_PATTERN.exec(lines[i]);
|
|
351
|
-
if (!m) continue;
|
|
352
|
-
if (open === null) {
|
|
353
|
-
open = { marker: m[1][0], len: m[1].length, lang: m[2] || '', startIdx: i };
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
// Ein schließender Zaun muss dasselbe Zeichen und mindestens dieselbe
|
|
357
|
-
// Länge haben — sonst beendet ein ```js-Zaun einen ````-Block.
|
|
358
|
-
if (m[1][0] !== open.marker || m[1].length < open.len || m[2]) continue;
|
|
359
|
-
fences.push({
|
|
360
|
-
openLine: open.startIdx + 1,
|
|
361
|
-
closeLine: i + 1,
|
|
362
|
-
lang: open.lang,
|
|
363
|
-
bodyLines: lines.slice(open.startIdx + 1, i),
|
|
364
|
-
bodyStartLine: open.startIdx + 2,
|
|
365
|
-
});
|
|
366
|
-
open = null;
|
|
367
|
-
}
|
|
368
|
-
return fences;
|
|
344
|
+
return scanFenceBlocks(content, { wholeLine: true });
|
|
369
345
|
}
|
|
370
346
|
|
|
371
347
|
// ---------------------------------------------------------------------------
|
|
@@ -23,6 +23,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { filterRealSessions } from './session-schema.mjs';
|
|
26
|
+
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Constants
|
|
@@ -245,6 +246,73 @@ export async function shouldDispatchAutoDialectic({
|
|
|
245
246
|
};
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Decision + mechanical telemetry (#1200 part c)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `shouldDispatchAutoDialectic()` plus a MECHANICAL `orchestrator.dialectic.nudge_decided`
|
|
255
|
+
* record, so the nudge decision is observable without depending on the
|
|
256
|
+
* session-end skill prose actually reaching the emit step (#1200: 0 records of
|
|
257
|
+
* this class across 164k fleet events despite the nudge firing every close).
|
|
258
|
+
*
|
|
259
|
+
* Contract-preserving wrapper: calls `shouldDispatchAutoDialectic()` unchanged
|
|
260
|
+
* and returns its decision object verbatim — the emit is a side effect bolted
|
|
261
|
+
* on, never a change to the decision logic or its return shape.
|
|
262
|
+
*
|
|
263
|
+
* Emits on ALL FOUR return paths (kill-switch, no-new-input,
|
|
264
|
+
* cadence-threshold-met, under-threshold) — best-effort, try/catch-wrapped,
|
|
265
|
+
* because `emitEvent()` throws `EventValidationError` on a malformed record
|
|
266
|
+
* and a telemetry failure must never change what the caller decides to do
|
|
267
|
+
* (same posture as `scripts/lib/reconcile/engine.mjs`'s `emitReconcileCompleted`
|
|
268
|
+
* wrapper).
|
|
269
|
+
*
|
|
270
|
+
* @param {object} args
|
|
271
|
+
* @param {string} args.repoRoot
|
|
272
|
+
* @param {number} [args.cadence=DEFAULT_CADENCE] `dialectic.cadence` from config.
|
|
273
|
+
* @param {object} [args.signals] Pre-computed signals (skips disk reads) — forwarded verbatim.
|
|
274
|
+
* @param {Function|null} [args.emitFn=emitEvent] DI hook for testing / disabling
|
|
275
|
+
* emission; defaults to `emitEvent` from `./events.mjs`. Any error it throws
|
|
276
|
+
* is swallowed — it never changes the returned decision.
|
|
277
|
+
* @param {boolean} [args.record=true] When `false`, no event is emitted at all —
|
|
278
|
+
* for read-only PROBE callers (e.g. the session-end Phase 3.6.x tail-skip
|
|
279
|
+
* aggregator, `scripts/lib/session-end/phase-skip.mjs`, whose documented
|
|
280
|
+
* contract is side-effect-free) where the decision is computed for internal
|
|
281
|
+
* branching only and must never itself be recorded as a nudge decision.
|
|
282
|
+
* @returns {Promise<{trigger:boolean, reason:string, signals:object}>}
|
|
283
|
+
*/
|
|
284
|
+
export async function decideAndRecordAutoDialectic({
|
|
285
|
+
repoRoot,
|
|
286
|
+
cadence = DEFAULT_CADENCE,
|
|
287
|
+
signals,
|
|
288
|
+
emitFn = emitEvent,
|
|
289
|
+
record = true,
|
|
290
|
+
} = {}) {
|
|
291
|
+
const decision = await shouldDispatchAutoDialectic({ repoRoot, cadence, signals });
|
|
292
|
+
|
|
293
|
+
if (record && typeof emitFn === 'function') {
|
|
294
|
+
try {
|
|
295
|
+
await emitFn(
|
|
296
|
+
'orchestrator.dialectic.nudge_decided',
|
|
297
|
+
{
|
|
298
|
+
...sessionAttribution(repoRoot),
|
|
299
|
+
decided: decision.trigger,
|
|
300
|
+
reason: decision.reason,
|
|
301
|
+
cadence,
|
|
302
|
+
sessions_since: decision.signals?.sessionsSinceLast,
|
|
303
|
+
learnings_since: decision.signals?.learningsSinceLast,
|
|
304
|
+
},
|
|
305
|
+
{ repoRoot },
|
|
306
|
+
);
|
|
307
|
+
} catch {
|
|
308
|
+
// best-effort — a telemetry failure must never block or change the
|
|
309
|
+
// decision the caller already has in hand.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return decision;
|
|
314
|
+
}
|
|
315
|
+
|
|
248
316
|
// ---------------------------------------------------------------------------
|
|
249
317
|
// last-run — atomic write
|
|
250
318
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +37,7 @@ import { emitEvent } from '../events.mjs';
|
|
|
37
37
|
import { main as gcMain } from '../../gc-stale-worktrees.mjs';
|
|
38
38
|
import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
39
39
|
import { repoPathHash } from '../session-registry.mjs';
|
|
40
|
+
import { leaveSourceRoot } from '../session-transition.mjs';
|
|
40
41
|
// Marker location is owned by its READER (session-end Phase 4a) — importing the
|
|
41
42
|
// constant from there keeps writer and reader on one string. The dependency runs
|
|
42
43
|
// heavy→lean (this pipeline → the dependency-free cleanup helper), never back.
|
|
@@ -731,6 +732,21 @@ function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
|
|
|
731
732
|
* @param {string} params.sessionId - Semantic session-ID matching SEMANTIC_ID_RE.
|
|
732
733
|
* @param {string} params.branch - Branch name (existing or new) matching ENTER_WORKTREE_BRANCH_RE.
|
|
733
734
|
* @param {string} params.repoRoot - Path to the source git repository (passed explicitly to avoid CWD drift per #219).
|
|
735
|
+
* @param {string} [params.rawSessionId] - The RAW (physical) session id that owns
|
|
736
|
+
* the SOURCE root's `session.lock` and registry entry. Optional. When it is a
|
|
737
|
+
* non-empty string, the source root is LEFT mechanically (#1170): after the
|
|
738
|
+
* destination worktree provably exists, `leaveSourceRoot()` runs and its
|
|
739
|
+
* result is reported as `left` in the return value. OMITTING it (strictly
|
|
740
|
+
* `undefined`) preserves the pre-#1170 behaviour exactly — no teardown, no
|
|
741
|
+
* `left` key, no WARN. Passing something UNUSABLE (`null`, `''`, a non-string —
|
|
742
|
+
* `readLock({ repoRoot }).session_id` returns `null` on a missing/unreadable
|
|
743
|
+
* lock, which is how this happens in practice) is NOT the same case: it WARNs
|
|
744
|
+
* and reports `left: { ok: false, reason: 'raw-session-id-unusable' }`, because
|
|
745
|
+
* a silent skip there is the phantom-owner state of #1069. NOT the semantic
|
|
746
|
+
* `sessionId` above, which is a naming key, never an ownership key.
|
|
747
|
+
* @param {string} [params.reason='worktree-promotion'] - Departure reason
|
|
748
|
+
* recorded verbatim in the `orchestrator.session.root_left` payload. Only used
|
|
749
|
+
* when `rawSessionId` is given.
|
|
734
750
|
* @param {object} [opts]
|
|
735
751
|
* @param {Function} [opts.$] - zx-like template-tag executor (DI seam); falls back to lazy `await import('zx')`.
|
|
736
752
|
* Every freshly created worktree also gets a `.orchestrator/promoted-from.json`
|
|
@@ -739,18 +755,27 @@ function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
|
|
|
739
755
|
* session with a different id. Marker writing is best-effort and never fails
|
|
740
756
|
* the promotion.
|
|
741
757
|
*
|
|
742
|
-
* @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true }>}
|
|
758
|
+
* @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true, left?: { ok: boolean, steps: object, reason?: string } }>}
|
|
743
759
|
* `branch` is the branch the new worktree actually landed on — equal to the
|
|
744
760
|
* `branch` param except in case 1 above, where it is `so/<sessionId>` and
|
|
745
761
|
* `promotedFrom` carries the requested source branch. `reusedBranch` is
|
|
746
762
|
* present (and `true`) only when an existing `so/<sessionId>` was checked out
|
|
747
763
|
* rather than created. All three are absent on the `reused: true` path (no
|
|
748
|
-
* branch was chosen — the worktree pre-existed).
|
|
764
|
+
* branch was chosen — the worktree pre-existed). `left` is present on BOTH
|
|
765
|
+
* success exits — and ONLY — when `rawSessionId` was supplied (including when
|
|
766
|
+
* it was supplied unusable, where it reports the refusal).
|
|
749
767
|
* @throws {TypeError} when any required param is missing or fails validation.
|
|
750
768
|
* @throws {WorktreeBoundaryError} when the computed worktree path escapes `basePath`.
|
|
751
769
|
* @throws {WorktreePromotionBranchError} when `so/<sessionId>` is checked out elsewhere.
|
|
752
770
|
*/
|
|
753
|
-
export async function enterWorktree({
|
|
771
|
+
export async function enterWorktree({
|
|
772
|
+
basePath,
|
|
773
|
+
sessionId,
|
|
774
|
+
branch,
|
|
775
|
+
repoRoot,
|
|
776
|
+
rawSessionId,
|
|
777
|
+
reason = 'worktree-promotion',
|
|
778
|
+
} = {}, opts = {}) {
|
|
754
779
|
// -------------------------------------------------------------------------
|
|
755
780
|
// Step 1: Input validation (TypeError on any malformed param).
|
|
756
781
|
// -------------------------------------------------------------------------
|
|
@@ -839,11 +864,62 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
|
|
|
839
864
|
);
|
|
840
865
|
}
|
|
841
866
|
|
|
867
|
+
// -------------------------------------------------------------------------
|
|
868
|
+
// Step 3b: The source-root departure (#1170), as a MECHANICAL step.
|
|
869
|
+
//
|
|
870
|
+
// Before #1170 this was skill PROSE at four sites: `enterWorktree()` created
|
|
871
|
+
// the destination and the coordinator was trusted to call `leaveSourceRoot()`
|
|
872
|
+
// afterwards. A step that only exists in prose is a step that is sometimes
|
|
873
|
+
// skipped — and skipping it is exactly the phantom-peer / double-live-lock
|
|
874
|
+
// state #1069 was filed about.
|
|
875
|
+
//
|
|
876
|
+
// Ordering is load-bearing and only runs on a proven-existing destination:
|
|
877
|
+
// every throw above leaves the source root untouched, because a departure
|
|
878
|
+
// from a root whose successor does not exist strands the session with no
|
|
879
|
+
// live root at all. `leaveSourceRoot()` never throws, so a failed teardown
|
|
880
|
+
// WARNs and is reported in `left` — it never fails the promotion.
|
|
881
|
+
// -------------------------------------------------------------------------
|
|
882
|
+
const departSourceRoot = async (result) => {
|
|
883
|
+
// NOT PASSED and PASSED-BUT-UNUSABLE are different callers with different
|
|
884
|
+
// bugs, and collapsing them re-opened #1069 silently. `undefined` is the
|
|
885
|
+
// documented opt-out (the autopilot callers, which own no source session):
|
|
886
|
+
// byte-identical pre-#1170 behaviour, no `left`, no WARN.
|
|
887
|
+
if (rawSessionId === undefined) return result;
|
|
888
|
+
// Anything else falsy or non-string means a caller INTENDED to depart and
|
|
889
|
+
// handed us something unusable. The documented derivation is
|
|
890
|
+
// `readLock({ repoRoot }).session_id`, which is `null` whenever the lock is
|
|
891
|
+
// missing or unreadable — so the realistic failure lands here, and before
|
|
892
|
+
// this branch it returned early with nothing on stderr: promotion succeeds,
|
|
893
|
+
// source root never deregistered, phantom owner (#1069).
|
|
894
|
+
if (typeof rawSessionId !== 'string' || rawSessionId.length === 0) {
|
|
895
|
+
const shown = rawSessionId === null
|
|
896
|
+
? 'null'
|
|
897
|
+
: typeof rawSessionId === 'string' ? 'empty string' : typeof rawSessionId;
|
|
898
|
+
console.warn(
|
|
899
|
+
`enterWorktree: rawSessionId unusable (${shown}) — source root NOT departed; ` +
|
|
900
|
+
'read it via readLock({ repoRoot }).session_id',
|
|
901
|
+
);
|
|
902
|
+
result.left = { ok: false, reason: 'raw-session-id-unusable' };
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
905
|
+
const left = await leaveSourceRoot({
|
|
906
|
+
repoRoot,
|
|
907
|
+
sessionId: rawSessionId,
|
|
908
|
+
semanticSessionId: sessionId,
|
|
909
|
+
reason,
|
|
910
|
+
});
|
|
911
|
+
if (left.ok !== true) {
|
|
912
|
+
console.warn(`enterWorktree: leaveSourceRoot: ${left.reason ?? 'unknown'}`);
|
|
913
|
+
}
|
|
914
|
+
result.left = left;
|
|
915
|
+
return result;
|
|
916
|
+
};
|
|
917
|
+
|
|
842
918
|
// -------------------------------------------------------------------------
|
|
843
919
|
// Step 4: Idempotency — reuse if worktree already exists with .git.
|
|
844
920
|
// -------------------------------------------------------------------------
|
|
845
921
|
if (fs.existsSync(wtPath) && fs.existsSync(path.join(wtPath, '.git'))) {
|
|
846
|
-
return { wtPath, reused: true };
|
|
922
|
+
return await departSourceRoot({ wtPath, reused: true });
|
|
847
923
|
}
|
|
848
924
|
|
|
849
925
|
// -------------------------------------------------------------------------
|
|
@@ -931,7 +1007,7 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
|
|
|
931
1007
|
// Present only when it happened, so the common shape stays byte-identical
|
|
932
1008
|
// for every existing consumer and strict-equality pin.
|
|
933
1009
|
if (reusedBranch) result.reusedBranch = true;
|
|
934
|
-
return result;
|
|
1010
|
+
return await departSourceRoot(result);
|
|
935
1011
|
}
|
|
936
|
-
return { wtPath, reused: false, branch };
|
|
1012
|
+
return await departSourceRoot({ wtPath, reused: false, branch });
|
|
937
1013
|
}
|
|
@@ -20,6 +20,7 @@ import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
|
20
20
|
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
21
21
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
22
22
|
import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
23
|
+
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Public API
|
|
@@ -113,29 +114,31 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
113
114
|
let recentSessions = [];
|
|
114
115
|
|
|
115
116
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
117
|
+
// #1186: readCanonicalSessions applies the #1167 newest-wins-per-`session_id`
|
|
118
|
+
// / attestable-`supersedes` collapse BEFORE the tail is taken. The raw read
|
|
119
|
+
// this replaced parsed every line unconditionally (malformed lines skipped
|
|
120
|
+
// on JSON.parse failure only), so a duplicated `session_id` — a
|
|
121
|
+
// crash-recovery re-append, or the #1068 abandoned-stub/supersede pair —
|
|
122
|
+
// counted as TWO entries toward `sessionTailN`, silently narrowing the
|
|
123
|
+
// REAL window by however many duplicates sat in the file's tail.
|
|
124
|
+
//
|
|
125
|
+
// Behavioural change: a well-formed record with NO `session_id` (never
|
|
126
|
+
// legitimate per REQUIRED_FIELDS, but possible on a hand-edited or
|
|
127
|
+
// pre-schema legacy line) is now DROPPED rather than counted — it cannot
|
|
128
|
+
// be deduplicated by identity, so `canonicalizeSessions` excludes it (see
|
|
129
|
+
// sessions-canonical.mjs's own contract). Missing-file / unreadable-file
|
|
130
|
+
// handling (ENOENT vs EACCES/EISDIR, #1188) is delegated to the shared
|
|
131
|
+
// reader; both still degrade to `[]` here, matching Branch 3's contract.
|
|
132
|
+
const canonical = readCanonicalSessions({ filePath: sessionsPath });
|
|
133
|
+
const parsed = canonical.map((obj) => normalizeSession(obj));
|
|
134
|
+
// #834: `status: 'abandoned'` phantom stubs must still be filtered out
|
|
135
|
+
// BEFORE the tail is taken, or sessionTailN silently means "last N LINES"
|
|
136
|
+
// instead of "last N REAL sessions". tailRealSessions() does the filter +
|
|
137
|
+
// tail.
|
|
138
|
+
recentSessions = tailRealSessions(parsed, sessionTailN);
|
|
137
139
|
} catch {
|
|
138
|
-
// Branch 3: file unreadable — recentSessions stays []
|
|
140
|
+
// Branch 3: file unreadable — recentSessions stays [] (readCanonicalSessions
|
|
141
|
+
// itself never throws, but the graceful-null contract is kept as a backstop).
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
// --- Branch 5: bootstrap.lock ---
|