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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. 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
- * The `transcript_path` in a SubagentStop payload is the PARENT session
493
- * transcript`<transcriptDir>/<parent_session_id>.jsonl`. Alongside it the
494
- * harness maintains one file per real Task subagent at
495
- * `<transcriptDir>/<parent_session_id>/subagents/agent-<agent_id>.jsonl`
496
- * (plus an `agent-<agent_id>.meta.json` carrying `agentType`). Verified against
497
- * live transcripts on 2026-07-31.
498
- *
499
- * Returns null — never the parent path when the derivation is not possible.
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
- if (typeof parentTranscriptPath !== 'string' || !parentTranscriptPath.trim()) return null;
515
- if (typeof agentId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(agentId)) return null;
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.23.0",
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",
@@ -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
- * Count the number of non-empty lines in sessions.jsonl. Returns 0 if missing.
178
- * @returns {number}
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 countSessionLines() {
181
- if (!existsSync(SESSIONS_JSONL_PATH)) return 0;
182
- try {
183
- const raw = readFileSync(SESSIONS_JSONL_PATH, 'utf8');
184
- return raw.split('\n').filter((l) => l.trim().length > 0).length;
185
- } catch {
186
- return 0;
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 preCount = countSessionLines();
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 postCount = countSessionLines();
285
- if (postCount === preCount) {
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
- /** Read a JSONL file into parsed objects; missing → []; malformed lines skipped. */
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
- const semanticByUuid = new Map();
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
- ev.event === LOCK_ACQUIRED &&
93
- typeof ev.session_id === 'string' &&
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 && typeof r.session_id === 'string') ids.add(r.session_id);
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
  *
@@ -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 payload)
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-Anker. **Zeilenanfangs-verankert** — ohne den Anker schließt
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
- const lines = content.split('\n');
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({ basePath, sessionId, branch, repoRoot } = {}, opts = {}) {
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
- if (existsSync(sessionsPath)) {
117
- const raw = readFileSync(sessionsPath, 'utf8');
118
- const lines = raw
119
- .split('\n')
120
- .map((l) => l.trim())
121
- .filter((l) => l.length > 0);
122
- // #834: parse ALL lines (not just the naive last-N) before windowing —
123
- // `status: 'abandoned'` phantom stubs must be filtered out BEFORE the
124
- // tail is taken, or sessionTailN silently means "last N LINES" instead
125
- // of "last N REAL sessions". tailRealSessions() does the filter + tail.
126
- const parsed = [];
127
- for (const line of lines) {
128
- try {
129
- const obj = JSON.parse(line);
130
- parsed.push(normalizeSession(obj));
131
- } catch {
132
- // Branch 4: skip malformed lines silently
133
- }
134
- }
135
- recentSessions = tailRealSessions(parsed, sessionTailN);
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 ---