session-orchestrator 5.4.0 → 5.5.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 (150) 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/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +95 -3
  7. package/README.md +11 -11
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +15 -13
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +21 -2
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * render-learnings.mjs — Learning markdown generators for vault-mirror (Issue #283 split).
3
3
  *
4
- * Exports: detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2
4
+ * Exports: detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2,
5
+ * sourceRecordValue, learningSourceRecord, sourceRecordLine (#1503)
5
6
  */
6
7
 
7
8
  import { toDate, truncateAtWord, yamlQuoteIfNeeded, subjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
@@ -18,6 +19,63 @@ export function detectLearningSchema(entry) {
18
19
  return entry && typeof entry.text === 'string' ? 'v2' : 'v1';
19
20
  }
20
21
 
22
+ /**
23
+ * Characters a `source-record:` value may carry (#1503). The value is always
24
+ * written double-quoted, and BOTH readers of it — the vault-sync YAML loader and
25
+ * the line-based `parseFrontmatter` in utils.mjs, which only strips the
26
+ * surrounding quotes and never unescapes — must read back exactly the string
27
+ * that was rendered. Excluding `"`, `\`, whitespace and anything non-ASCII is
28
+ * what makes that round trip lossless without an escaping scheme. Record uuids,
29
+ * kebab ids, session uuids and ISO timestamps all fit.
30
+ */
31
+ const SOURCE_RECORD_RE = /^[A-Za-z0-9][A-Za-z0-9._:+@/-]{0,199}$/;
32
+
33
+ /**
34
+ * Normalise one candidate `source-record` value (#1503): the identity of the
35
+ * JSONL record a vault note was rendered from. Returns `null` for anything that
36
+ * cannot round-trip through the frontmatter (see {@link SOURCE_RECORD_RE}) — the
37
+ * line is then omitted and the record-level ownership check is skipped for that
38
+ * entry, which is the same "absent = allow" reading an old note gets.
39
+ *
40
+ * Shared by both renderers so the rendered value and the value process.mjs
41
+ * compares against come from ONE derivation.
42
+ *
43
+ * @param {unknown} value
44
+ * @returns {string|null}
45
+ */
46
+ export function sourceRecordValue(value) {
47
+ if (typeof value !== 'string') return null;
48
+ const trimmed = value.trim();
49
+ return SOURCE_RECORD_RE.test(trimmed) ? trimmed : null;
50
+ }
51
+
52
+ /**
53
+ * The `source-record` of a learning note: the record's own `id` (a uuid for v1
54
+ * producers, the kebab id for v2 and for alias producers whose id was derived
55
+ * from the subject by {@link normalizeLearningEntry}).
56
+ *
57
+ * Named ceiling: a derived id IS the subject slug, so two repos holding the same
58
+ * subject under derived ids render the same `source-record` and this field
59
+ * cannot tell them apart — `source-repo` and the CLI's repo-root resolution are
60
+ * what separate those.
61
+ *
62
+ * @param {object} entry - normalized learning entry.
63
+ * @returns {string|null}
64
+ */
65
+ export function learningSourceRecord(entry) {
66
+ return sourceRecordValue(entry?.id);
67
+ }
68
+
69
+ /**
70
+ * Render the `source-record:` frontmatter line, or `''` when the entry has no
71
+ * representable record key.
72
+ * @param {string|null} value - output of {@link sourceRecordValue}.
73
+ * @returns {string}
74
+ */
75
+ export function sourceRecordLine(value) {
76
+ return value === null ? '' : `source-record: "${value}"\n`;
77
+ }
78
+
21
79
  const firstString = (...vals) => vals.find((v) => typeof v === 'string' && v.length > 0);
22
80
 
23
81
  const joinList = (v) =>
@@ -112,6 +170,9 @@ export function generateLearningNote(entry, slug, opts = {}) {
112
170
  // vault-sync schema declares source-repo OPTIONAL).
113
171
  const sourceRepoLine =
114
172
  typeof opts.repoNs === 'string' && opts.repoNs.length > 0 ? `source-repo: ${opts.repoNs}\n` : '';
173
+ // #1503: the record this note was rendered from — process.mjs refuses to
174
+ // overwrite a note whose `source-record` names a different record.
175
+ const recordLine = sourceRecordLine(learningSourceRecord(entry));
115
176
 
116
177
  // source_session is emitted as an Obsidian wikilink ONLY when source_session
117
178
  // resolves to a real, mirror-able session id (semantic or UUID-v4). Anything
@@ -132,7 +193,7 @@ created: ${created}
132
193
  updated: ${updated}
133
194
  tags: ${tags}
134
195
  source_session: ${sourceSessionLink}
135
- ${expiresLine}${sourceRepoLine}_generator: ${GENERATOR_MARKER}
196
+ ${expiresLine}${sourceRepoLine}${recordLine}_generator: ${GENERATOR_MARKER}
136
197
  ---
137
198
 
138
199
  # ${titleRaw}
@@ -176,6 +237,8 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
176
237
  // opts.repoNs (see generateLearningNote for the full contract). Backward-compatible.
177
238
  const sourceRepoLine =
178
239
  typeof opts.repoNs === 'string' && opts.repoNs.length > 0 ? `source-repo: ${opts.repoNs}\n` : '';
240
+ // #1503: see generateLearningNote.
241
+ const recordLine = sourceRecordLine(learningSourceRecord(entry));
179
242
 
180
243
  return `---
181
244
  id: ${slug}
@@ -185,7 +248,7 @@ status: ${status}
185
248
  created: ${created}
186
249
  updated: ${updated}
187
250
  tags: ${tags}
188
- ${sourceRepoLine}_generator: ${GENERATOR_MARKER}
251
+ ${sourceRepoLine}${recordLine}_generator: ${GENERATOR_MARKER}
189
252
  ---
190
253
 
191
254
  # ${titleRaw}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * render-sessions.mjs — Session markdown generators for vault-mirror (Issue #283 split).
3
3
  *
4
- * Exports: detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3
4
+ * Exports: detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3,
5
+ * sessionSourceRecord (#1503)
5
6
  *
6
7
  * #732: all three generators accept `options.repoNs` (the leak-guarded /
7
8
  * pseudonym-mapped namespace segment from resolveRepoNamespace(), threaded
@@ -15,6 +16,7 @@
15
16
  */
16
17
 
17
18
  import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
19
+ import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
18
20
 
19
21
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
20
22
 
@@ -282,6 +284,28 @@ function fmLine(key, value) {
282
284
  return `${key}: ${value}\n`;
283
285
  }
284
286
 
287
+ /**
288
+ * The `source-record` of a session note (#1503): the physical session the note
289
+ * was rendered from — `raw_session_id` (the harness's session uuid) when the
290
+ * record carries one, else `started_at`. `session_id` itself cannot serve: it is
291
+ * the FILENAME, and semantic ids such as `main-2026-10-02-session-1` recur across
292
+ * repos by construction, which is exactly the collision this field exists to
293
+ * detect. All three generators require `started_at`, so every renderable record
294
+ * has a key.
295
+ *
296
+ * Named ceiling: a record whose key changes between runs — a later backfill
297
+ * adding `raw_session_id` to a record first mirrored under its `started_at` —
298
+ * reads as a different record, and process.mjs then refuses the rewrite
299
+ * (`skipped-foreign-owner`, visible on stdout). Revisit if that is ever observed
300
+ * on a same-repo record.
301
+ *
302
+ * @param {object} entry - normalized session entry.
303
+ * @returns {string|null}
304
+ */
305
+ export function sessionSourceRecord(entry) {
306
+ return sourceRecordValue(entry?.raw_session_id) ?? sourceRecordValue(entry?.started_at);
307
+ }
308
+
285
309
  /**
286
310
  * Session JSONL has three producer schemas in production:
287
311
  * v1 (legacy): total_waves, total_agents, total_files_changed, agent_summary, waves[{agent_count, files_changed, quality}]
@@ -521,7 +545,7 @@ status: ${vaultStatus}
521
545
  created: ${created}
522
546
  updated: ${updated}
523
547
  tags: ${tags}
524
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
548
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
525
549
  ---
526
550
 
527
551
  # Session ${session_id}
@@ -606,7 +630,7 @@ status: ${vaultStatus}
606
630
  created: ${created}
607
631
  updated: ${updated}
608
632
  tags: ${tags}
609
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
633
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
610
634
  ---
611
635
 
612
636
  # Session ${session_id}
@@ -731,7 +755,7 @@ status: ${vaultStatus}
731
755
  created: ${created}
732
756
  updated: ${updated}
733
757
  tags: ${tags}
734
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
758
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
735
759
  ---
736
760
 
737
761
  # Session ${session_id}
@@ -15,6 +15,12 @@
15
15
  * a `--kind session` line whose `session_id` a later line in the same
16
16
  * source superseded, so it was never dispatched. It carries `record_id`
17
17
  * and `line` and never `path` — no target file exists for it.
18
+ * `skipped-foreign-owner` (#1503) is another: the generator note at the
19
+ * target path names a different owner (a `source-record` the writer's
20
+ * ledger does not carry, or a different session id), so it was left
21
+ * untouched — unless it still leaks a secret, which is healed over the
22
+ * guard. It carries the refused note's `path` and a constant `reason`
23
+ * (`source-record mismatch` / `id mismatch`).
18
24
  * - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
19
25
  * carrying the DENOMINATOR (`total` plus the per-class counts).
20
26
  *
@@ -25,8 +31,8 @@
25
31
  * "a rule you cannot falsify is not a rule"). The run event is emitted
26
32
  * unconditionally — including on every abort path, where it carries an
27
33
  * `aborted` discriminator (`missing-vault-dir` | `vault-not-canonical` |
28
- * `missing-source` | `malformed-json` | `filesystem-error` |
29
- * `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
34
+ * `missing-source` | `source-repo-mismatch` | `malformed-json` |
35
+ * `filesystem-error` | `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
30
36
  * LABELLED partial rather than a silent one, and the record's absence — and
31
37
  * nothing else — is the broken-emitter signal.
32
38
  *
@@ -36,9 +42,10 @@
36
42
  *
37
43
  * Ledger destination: `emitEvent` is called 2-arg, so every event from one run
38
44
  * resolves the SAME destination (`SO_PROJECT_DIR`, i.e. `CLAUDE_PROJECT_DIR` or
39
- * the CWD walk-up) as the masker emit. This CLI has no repo-root flag and
40
- * deriving one from `--source` would split a single run's telemetry across two
41
- * ledgers.
45
+ * the CWD walk-up) as the masker emit. The repo root that decides the vault
46
+ * NAMESPACE (#1503: `--repo-root`, else the git repo containing `--source`) is
47
+ * deliberately NOT used for the ledger — routing telemetry by it would split a
48
+ * single run's records across two ledgers.
42
49
  */
43
50
 
44
51
  import { emitEvent, sessionAttribution } from '../events.mjs';
@@ -166,7 +173,7 @@ export async function emitMirrorEvent({
166
173
  * @param {number} opts.updated — entries whose action was `updated`.
167
174
  * @param {number} opts.skipped — entries skipped for a NON-failure reason
168
175
  * (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
169
- * `skipped-collision-resolved`, `skipped-abandoned`,
176
+ * `skipped-collision-resolved`, `skipped-foreign-owner`, `skipped-abandoned`,
170
177
  * `skipped-duplicate-session`). The last one (#1291) is what keeps
171
178
  * `created + updated + skipped + failed === total` true for a `--kind
172
179
  * session` run whose dedup pass collapsed a duplicate `session_id` line.
@@ -176,13 +183,13 @@ export async function emitMirrorEvent({
176
183
  * @param {Record<string, number>} [opts.actionBreakdown] — per-action counts;
177
184
  * only actions observed at least once appear.
178
185
  * @param {boolean} opts.dryRun — whether this run wrote anything at all.
179
- * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
186
+ * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'source-repo-mismatch'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
180
187
  * Present ONLY when the run exited before its normal tail. OMITTED on a
181
188
  * complete run — absent means "ran to the end", never "unknown". The first
182
- * three values are PRE-LOOP aborts (#1151): the run never reached its first
183
- * entry, so all five counters are `0` and that zero is the point — without a
184
- * record, a mirror that never started is indistinguishable from one that was
185
- * never invoked.
189
+ * four values are PRE-LOOP aborts (#1151, #1503): the run never reached its
190
+ * first entry, so all five counters are `0` and that zero is the point —
191
+ * without a record, a mirror that never started is indistinguishable from one
192
+ * that was never invoked.
186
193
  * @returns {Promise<void>}
187
194
  */
188
195
  export async function emitMirrorRunEvent({
@@ -360,8 +360,12 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
360
360
  ? lock.mode
361
361
  : (registryEntry && registryEntry.mode ? registryEntry.mode : null);
362
362
 
363
+ // The "Last heartbeat" column shows a heartbeat or nothing: a lock without
364
+ // `last_heartbeat` renders '—', never its `started_at` under that label
365
+ // (#595 sunset, 2026-10-02 — `isLockLive()` already calls it not live, so
366
+ // the status derivation below treats it as a dead lease).
363
367
  const heartbeat = lock
364
- ? (lock.last_heartbeat ?? lock.started_at ?? null)
368
+ ? (lock.last_heartbeat ?? null)
365
369
  : (registryEntry ? (registryEntry.last_heartbeat ?? null) : null);
366
370
 
367
371
  // ── Status derivation ──
@@ -160,6 +160,14 @@ export function stripUrlCredentials(url) {
160
160
  * `--repo <spec>` override path, which bypasses the source-level strip in
161
161
  * {@link resolveRawRemoteUrl}. Never throws; a non-string returns unchanged.
162
162
  *
163
+ * Scope is CREDENTIALS only, as {@link userinfoIsCredential} classifies them.
164
+ * A bare login on a non-HTTP scheme (`ssh://alice@host/…`) is left verbatim,
165
+ * the URL path is never touched (`ssh://***@host/home/alice/vault` keeps the
166
+ * remote home dir), and the scp-like `user@host:path` form is not matched at
167
+ * all. So this is NOT the scp-consistent origin renderer: `describeOriginForLog`
168
+ * (`named-vault-resolver.mjs`) redacts every non-`git` login in both forms and
169
+ * hides a home-dir path (#1490) — use that for a git origin in a log line.
170
+ *
163
171
  * @param {string} text
164
172
  * @returns {string}
165
173
  */
@@ -26,7 +26,7 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
26
26
  import { dirname, resolve } from 'node:path';
27
27
  import { writeJsonAtomicSync } from './lib/io.mjs';
28
28
  import { isMainModule } from './lib/is-main-module.mjs';
29
- import { isPeerRecordId } from './lib/scope-gate.mjs';
29
+ import { COORDINATOR_RECORD_ID, isCoordinatorRecordId, isPeerRecordId } from './lib/scope-gate.mjs';
30
30
  import { SCOPE_MATERIALIZED_EVENT, scopeDigest } from './lib/scope-echo.mjs';
31
31
  import {
32
32
  MANIFEST_SESSION_KEYS,
@@ -214,8 +214,8 @@ export function validateScopeRecords(value) {
214
214
  throw new InputError(`record #${recordIndex} has duplicate id (case-insensitive): ${record.id}`);
215
215
  }
216
216
  seenIds.add(idKey);
217
- if (idKey === 'coordinator') {
218
- if (record.id !== 'coordinator') {
217
+ if (idKey === COORDINATOR_RECORD_ID) {
218
+ if (!isCoordinatorRecordId(record.id)) {
219
219
  throw new InputError(`record #${recordIndex} id must be exactly coordinator (lowercase)`);
220
220
  }
221
221
  coordinatorCount++;
@@ -537,7 +537,10 @@ export function transportObservable(pluginRoot = resolve(import.meta.dirname, '.
537
537
  async function emitScopeMaterialized({ stateDir, wave, records }) {
538
538
  try {
539
539
  const repoRoot = dirname(resolve(stateDir));
540
- const agentRecords = records.filter((r) => !isPeerRecordId(r.id));
540
+ // Dispatched agents only: a peer's record gets no per-agent file, and the
541
+ // coordinator's is a declaration no dispatch carries — counted, its empty
542
+ // `files` (the common case) read as "two agents share one scope" (#1493.2).
543
+ const agentRecords = records.filter((r) => !isPeerRecordId(r.id) && !isCoordinatorRecordId(r.id));
541
544
  const digests = new Set();
542
545
  for (const record of agentRecords) {
543
546
  if (Array.isArray(record.files) && record.files.length > 0) digests.add(scopeDigest(record.files));
@@ -1681,6 +1681,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
1681
1681
  log('\nPost-release checklist (manual):');
1682
1682
  log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
1683
1683
  log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
1684
+ log(' 3. Install the release on this host: npm run update:local -- --skip-pull, then restart the harness.');
1684
1685
  return 0;
1685
1686
  }
1686
1687
 
@@ -502,12 +502,24 @@ process.stderr.write(
502
502
  `${aggregate.totalStillInvalidPost} still-invalid\n`
503
503
  );
504
504
 
505
+ let exitCode = 0;
505
506
  if (lockFailedRepos.length > 0) {
506
507
  process.stderr.write(
507
508
  `run-migrate-v2: ERROR learnings store lock not acquired, nothing written in ${lockFailedRepos.length} repo(s): ` +
508
509
  `${lockFailedRepos.join(', ')}\n`
509
510
  );
510
- process.exit(2);
511
+ exitCode = 2;
511
512
  }
512
513
 
513
- process.exit(0);
514
+ // #1487.15: every other `status: 'error'` (read failed / write failed) is an
515
+ // I/O error too — exit 2 as the header and --help document, never 0.
516
+ const ioFailed = results.filter((r) => r.status === 'error' && !lockFailedRepos.includes(r.repo));
517
+ if (ioFailed.length > 0) {
518
+ process.stderr.write(
519
+ `run-migrate-v2: ERROR I/O error in ${ioFailed.length} repo(s): ` +
520
+ `${ioFailed.map((r) => `${r.repo} (${r.error})`).join(', ')}\n`
521
+ );
522
+ exitCode = 2;
523
+ }
524
+
525
+ process.exit(exitCode);
@@ -55,8 +55,8 @@ import {
55
55
  resolveGateTimeoutMs,
56
56
  } from './lib/gates/gate-helpers.mjs';
57
57
  import { buildCommandSignature, spawnInGroup } from './lib/process-group.mjs';
58
- import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
59
- import { findScopeFile } from './lib/scope-gate.mjs';
58
+ import { classifyManifestSession, readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
59
+ import { findOwnScopeFile } from './lib/scope-gate.mjs';
60
60
  import { gateKillFields } from './lib/quality-gate.mjs';
61
61
  import { isMainModule } from './lib/is-main-module.mjs';
62
62
  import { shellQuote } from './lib/sh-quote.mjs';
@@ -271,8 +271,10 @@ function resolveLedgerRoot(value) {
271
271
  *
272
272
  * Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
273
273
  * same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
274
- * {@link findScopeFile} — with ONE deliberate difference: the hook returns `0`
275
- * for "no wave-scope file", this returns `null`.
274
+ * {@link findOwnScopeFile}, skipping a PEER session's manifest in the same
275
+ * working copy (#1504: a peer-only result is no wave of ours, never the peer's
276
+ * number) — with ONE deliberate difference: the hook returns `0` for "no
277
+ * wave-scope file", this returns `null`.
276
278
  *
277
279
  * Absent is not zero. A human running `npm run quality-gate` from a `git push`
278
280
  * has no wave at all, and that is the common case; publishing `wave_number: 0`
@@ -289,7 +291,8 @@ function resolveLedgerRoot(value) {
289
291
  */
290
292
  function resolveWaveNumber(projectDir) {
291
293
  try {
292
- const waveFile = findScopeFile(projectDir);
294
+ const ownIds = new Set(readProcessLocalSessionIds({ env: process.env, hookInput: null }));
295
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
293
296
  if (!waveFile || !existsSync(waveFile)) return null;
294
297
  const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
295
298
  if (typeof wave !== 'number' || !Number.isFinite(wave) || wave <= 0) return null;
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bring every LOCAL install of Session Orchestrator to the newest version in
4
+ * one command: this clone, the Claude Code plugin, and the Codex plugin.
5
+ *
6
+ * WHY THIS EXISTS: an update was three independent manual acts, and the
7
+ * second one has a silent hole. Measured 2026-10-02 (5.3.0 → 5.4.0, Claude
8
+ * Code 2.1.287): `claude plugin update` succeeded but left the cache WITHOUT
9
+ * node_modules — `claude plugin list --json` carried
10
+ * `noteDetails: [{type: "dependencies-refused"}]` because package.json sets
11
+ * `overrides`. Hooks survive that (no static third-party imports), skill
12
+ * scripts and the MCP server do not. This script reads that state back and
13
+ * installs the runtime dependencies itself.
14
+ *
15
+ * Steps (each skipped when its harness is not installed on this host):
16
+ * 1. repo — `git fetch origin` + `git merge --ff-only origin/main`, then
17
+ * `npm ci` when HEAD moved or node_modules is missing.
18
+ * 2. claude — marketplace update + plugin update, then verify version and
19
+ * runtime dependencies in the reported installPath.
20
+ * 3. codex — only if the plugin is already installed in Codex: re-run
21
+ * scripts/codex-install.mjs (refreshes the installed bundle).
22
+ *
23
+ * Never deletes an old plugin cache directory: the running session still
24
+ * loads it until the harness restarts.
25
+ *
26
+ * Exit codes: 0 all steps ok/skipped · 1 usage error · 2 a step failed.
27
+ */
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ import { isMainModule } from './lib/is-main-module.mjs';
35
+
36
+ const SO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
37
+ const PLUGIN_ID = 'session-orchestrator@kanevry';
38
+ const MARKETPLACE = 'kanevry';
39
+ const COMMAND_TIMEOUT_MS = 300_000;
40
+ // `codex plugin list --available --json` is 2.56 MB on a host with several
41
+ // marketplaces (measured 2026-10-02); the 1 MiB default fails with ENOBUFS.
42
+ const MAX_BUFFER = 64 * 1024 * 1024;
43
+
44
+ /**
45
+ * Pick the session-orchestrator entry from `claude plugin list --json`.
46
+ * Accepts both the bare-array and the `{plugins: [...]}` shape.
47
+ * @param {unknown} listJson
48
+ * @returns {{version?: string, installPath?: string, enabled?: boolean, noteDetails?: Array<{type?: string}>} | null}
49
+ */
50
+ export function findClaudeEntry(listJson) {
51
+ const entries = Array.isArray(listJson) ? listJson : listJson?.plugins;
52
+ if (!Array.isArray(entries)) return null;
53
+ return entries.find((e) => e?.id === PLUGIN_ID && e?.scope === 'user')
54
+ ?? entries.find((e) => e?.id === PLUGIN_ID)
55
+ ?? null;
56
+ }
57
+
58
+ /**
59
+ * Runtime dependencies of the package at `dir` that have no directory under
60
+ * `dir/node_modules`. A presence check, not a version check: its job is to
61
+ * catch the harness having skipped the install entirely.
62
+ * @param {string} dir
63
+ * @returns {string[]}
64
+ */
65
+ export function missingRuntimeDeps(dir) {
66
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
67
+ return Object.keys(pkg.dependencies ?? {})
68
+ .filter((name) => !existsSync(path.join(dir, 'node_modules', name)));
69
+ }
70
+
71
+ function parseArguments(argv) {
72
+ const opts = { dryRun: false, json: false, skipPull: false, help: false };
73
+ for (const arg of argv) {
74
+ if (arg === '--dry-run') opts.dryRun = true;
75
+ else if (arg === '--json') opts.json = true;
76
+ else if (arg === '--skip-pull') opts.skipPull = true;
77
+ else if (arg === '--help' || arg === '-h') opts.help = true;
78
+ else throw Object.assign(new Error(`Unknown argument '${arg}'. Run with --help.`), { usage: true });
79
+ }
80
+ return opts;
81
+ }
82
+
83
+ const HELP = `Usage: node scripts/self-update.mjs [--dry-run] [--skip-pull] [--json]
84
+
85
+ Update this clone, the Claude Code plugin and (if installed) the Codex plugin
86
+ to the newest Session Orchestrator, and verify each one afterwards.
87
+
88
+ --dry-run Print what would run; change nothing
89
+ --skip-pull Leave the clone as it is (e.g. right after a release)
90
+ --json Print the per-step result as JSON
91
+ `;
92
+
93
+ function makeRunner({ dryRun, json }) {
94
+ const log = (line) => { if (!json) process.stdout.write(`${line}\n`); };
95
+ /** Run a command; read-only queries also run under --dry-run. */
96
+ function run(cmd, args, { cwd = SO_ROOT, mutates = true } = {}) {
97
+ const shown = `${cmd} ${args.join(' ')}`;
98
+ if (dryRun && mutates) {
99
+ log(` [dry-run] ${shown}${cwd === SO_ROOT ? '' : ` (in ${cwd})`}`);
100
+ return { ok: true, stdout: '' };
101
+ }
102
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: MAX_BUFFER });
103
+ if (r.error) return { ok: false, detail: `${shown}: ${r.error.message}` };
104
+ if (r.status !== 0) {
105
+ return { ok: false, detail: `${shown} exited ${r.status}: ${(r.stderr || r.stdout || '').trim().slice(-400)}` };
106
+ }
107
+ return { ok: true, stdout: r.stdout };
108
+ }
109
+ return { run, log };
110
+ }
111
+
112
+ function onPath(bin) {
113
+ return spawnSync('which', [bin], { encoding: 'utf8' }).status === 0;
114
+ }
115
+
116
+ function repoVersion() {
117
+ return JSON.parse(readFileSync(path.join(SO_ROOT, 'package.json'), 'utf8')).version;
118
+ }
119
+
120
+ function updateRepo({ run, log }, opts) {
121
+ if (opts.skipPull) return { step: 'repo', status: 'skipped', detail: '--skip-pull' };
122
+ const before = run('git', ['rev-parse', 'HEAD'], { mutates: false });
123
+ if (!before.ok) return { step: 'repo', status: 'failed', detail: before.detail };
124
+ const branch = run('git', ['branch', '--show-current'], { mutates: false }).stdout?.trim();
125
+ if (branch !== 'main') {
126
+ return { step: 'repo', status: 'failed', detail: `clone is on '${branch}', not main — switch or use --skip-pull` };
127
+ }
128
+ log('repo: fetching origin and fast-forwarding main');
129
+ for (const [cmd, args] of [['git', ['fetch', 'origin']], ['git', ['merge', '--ff-only', 'origin/main']]]) {
130
+ const r = run(cmd, args);
131
+ if (!r.ok) return { step: 'repo', status: 'failed', detail: r.detail };
132
+ }
133
+ const after = run('git', ['rev-parse', 'HEAD'], { mutates: false });
134
+ const moved = before.stdout !== after.stdout;
135
+ if (moved || !existsSync(path.join(SO_ROOT, 'node_modules'))) {
136
+ const r = run('npm', ['ci']);
137
+ if (!r.ok) return { step: 'repo', status: 'failed', detail: r.detail };
138
+ }
139
+ return { step: 'repo', status: 'ok', detail: `${repoVersion()}${moved ? ' (updated)' : ' (already current)'}` };
140
+ }
141
+
142
+ function updateClaude({ run, log }, opts) {
143
+ if (!onPath('claude')) return { step: 'claude', status: 'skipped', detail: 'claude not on PATH' };
144
+ log('claude: refreshing marketplace and plugin');
145
+ for (const args of [['plugin', 'marketplace', 'update', MARKETPLACE], ['plugin', 'update', PLUGIN_ID]]) {
146
+ const r = run('claude', args);
147
+ if (!r.ok) return { step: 'claude', status: 'failed', detail: r.detail };
148
+ }
149
+ const list = run('claude', ['plugin', 'list', '--json'], { mutates: false });
150
+ if (!list.ok) return { step: 'claude', status: 'failed', detail: list.detail };
151
+ let entry;
152
+ try { entry = findClaudeEntry(JSON.parse(list.stdout)); } catch (e) {
153
+ return { step: 'claude', status: 'failed', detail: `plugin list --json unparseable: ${e.message}` };
154
+ }
155
+ if (!entry) return { step: 'claude', status: 'skipped', detail: `${PLUGIN_ID} not installed` };
156
+ if (!entry.installPath || !existsSync(entry.installPath)) {
157
+ return { step: 'claude', status: 'failed', detail: `installPath missing: ${entry.installPath}` };
158
+ }
159
+ const missing = missingRuntimeDeps(entry.installPath);
160
+ if (missing.length > 0) {
161
+ const refused = (entry.noteDetails ?? []).some((n) => n?.type === 'dependencies-refused');
162
+ log(`claude: ${missing.length} runtime deps missing${refused ? ' (harness refused the install)' : ''} — installing`);
163
+ const r = run('npm', ['ci', '--omit=dev'], { cwd: entry.installPath });
164
+ if (!r.ok) return { step: 'claude', status: 'failed', detail: r.detail };
165
+ const still = opts.dryRun ? [] : missingRuntimeDeps(entry.installPath);
166
+ if (still.length > 0) return { step: 'claude', status: 'failed', detail: `still missing after npm ci: ${still.join(', ')}` };
167
+ }
168
+ const want = repoVersion();
169
+ // The marketplace tracks GitHub main, so it can trail this clone until the
170
+ // release is pushed — report it, do not fail on it.
171
+ const lag = entry.version !== want ? `; clone is ${want} — marketplace not yet at it` : '';
172
+ return {
173
+ step: 'claude',
174
+ status: 'ok',
175
+ detail: `${entry.version}${missing.length ? `, installed ${missing.length} missing deps` : ''}${lag}`,
176
+ };
177
+ }
178
+
179
+ function updateCodex({ run, log }, opts) {
180
+ if (!onPath('codex')) return { step: 'codex', status: 'skipped', detail: 'codex not on PATH' };
181
+ const list = run('codex', ['plugin', 'list'], { mutates: false });
182
+ if (!list.ok) return { step: 'codex', status: 'failed', detail: list.detail };
183
+ if (!new RegExp(`^${PLUGIN_ID}\\s+installed`, 'm').test(list.stdout)) {
184
+ return { step: 'codex', status: 'skipped', detail: 'plugin not installed in Codex (install: node scripts/codex-install.mjs)' };
185
+ }
186
+ log('codex: re-running codex-install');
187
+ const r = run(process.execPath, [path.join(SO_ROOT, 'scripts', 'codex-install.mjs'), '--json']);
188
+ if (!r.ok) return { step: 'codex', status: 'failed', detail: r.detail };
189
+ if (opts.dryRun) return { step: 'codex', status: 'ok', detail: 'dry-run' };
190
+ try {
191
+ return { step: 'codex', status: 'ok', detail: JSON.parse(r.stdout).pluginVersion };
192
+ } catch {
193
+ return { step: 'codex', status: 'ok', detail: 'installed (version not reported)' };
194
+ }
195
+ }
196
+
197
+ function main(opts) {
198
+ const io = makeRunner(opts);
199
+ const results = [];
200
+ // Order matters: the Codex installer installs FROM this clone, so the clone
201
+ // must be current first. A failed repo step stops the chain for that reason.
202
+ results.push(updateRepo(io, opts));
203
+ if (results[0].status !== 'failed') {
204
+ results.push(updateClaude(io, opts));
205
+ results.push(updateCodex(io, opts));
206
+ }
207
+ const ok = results.every((r) => r.status !== 'failed');
208
+ if (opts.json) {
209
+ process.stdout.write(`${JSON.stringify({ ok, dryRun: opts.dryRun, results }, null, 2)}\n`);
210
+ } else {
211
+ io.log('');
212
+ for (const r of results) io.log(` ${r.status.padEnd(7)} ${r.step.padEnd(7)} ${r.detail}`);
213
+ io.log(ok
214
+ ? '\nRestart Claude Code / Codex to load the new version. Old cache folders stay until then — delete them only after the restart.'
215
+ : '\nA step failed (a failed repo step skips the plugin steps). Fix it and re-run — every step is idempotent.');
216
+ }
217
+ return ok ? 0 : 2;
218
+ }
219
+
220
+ if (isMainModule(import.meta.url)) {
221
+ try {
222
+ const opts = parseArguments(process.argv.slice(2));
223
+ if (opts.help) process.stdout.write(HELP);
224
+ else process.exitCode = main(opts);
225
+ } catch (error) {
226
+ process.stderr.write(`ERROR: ${error.message}\n`);
227
+ process.exitCode = error.usage ? 1 : 2;
228
+ }
229
+ }