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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- 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` | `
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
*
|
|
183
|
-
* entry, so all five counters are `0` and that zero is the point —
|
|
184
|
-
* record, a mirror that never started is indistinguishable from one
|
|
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 ??
|
|
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 ===
|
|
218
|
-
if (record.id
|
|
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
|
-
|
|
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));
|
package/scripts/release.mjs
CHANGED
|
@@ -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
|
-
|
|
511
|
+
exitCode = 2;
|
|
511
512
|
}
|
|
512
513
|
|
|
513
|
-
|
|
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 {
|
|
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
|
|
275
|
-
*
|
|
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
|
|
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
|
+
}
|