session-orchestrator 3.23.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
* the engine performs is into the reconcile-candidates sidecar (via
|
|
34
34
|
* idempotency.mjs), and even that is skipped under `dryRun`.
|
|
35
35
|
*
|
|
36
|
-
* ── never-throws contract
|
|
37
|
-
* `runReconcile` NEVER throws to its caller
|
|
38
|
-
* degrades to a recorded rejection (never a
|
|
39
|
-
* error returns a zeroed result
|
|
36
|
+
* ── never-throws contract (PIPELINE errors only) ─────────────────────────────
|
|
37
|
+
* Once the pipeline is running, `runReconcile` NEVER throws to its caller: a
|
|
38
|
+
* per-learning emit/render failure degrades to a recorded rejection (never a
|
|
39
|
+
* crash), and any unexpected top-level pipeline error returns a zeroed result
|
|
40
|
+
* with an `error` field. The ONE exception is a caller-INPUT validation error
|
|
41
|
+
* at the very top of `runReconcile` — an explicitly-passed `trigger` outside
|
|
42
|
+
* `KNOWN_TRIGGERS` (issue #1201 Part A) THROWS synchronously before the
|
|
43
|
+
* pipeline ever starts, the same class of failure as passing a malformed
|
|
44
|
+
* `params` object to any other function in this codebase; see
|
|
45
|
+
* `assertKnownTrigger` below.
|
|
40
46
|
*
|
|
41
47
|
* Plain Node ESM, no external deps — Node 20+ stdlib + the four siblings only.
|
|
42
48
|
*
|
|
@@ -463,7 +469,7 @@ function defaultReadMaterializedProvenance(repoRoot) {
|
|
|
463
469
|
* @param {boolean} [opts.dryRun] - when true, compute proposals but SKIP the merge entirely.
|
|
464
470
|
* @returns {Promise<ReconcileResult>}
|
|
465
471
|
*/
|
|
466
|
-
|
|
472
|
+
async function runReconcileInner(
|
|
467
473
|
{
|
|
468
474
|
repoRoot,
|
|
469
475
|
ruleExpiryDays,
|
|
@@ -780,3 +786,228 @@ export async function runReconcile(
|
|
|
780
786
|
return zeroedResult(msg);
|
|
781
787
|
}
|
|
782
788
|
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Ledger name of the reconcile run event (issue #1192). Catalogued in
|
|
792
|
+
* `docs/events-schema.md`; `events-schema.mjs` needs no registration — it
|
|
793
|
+
* validates the NAME shape only, and this name already satisfies it.
|
|
794
|
+
*/
|
|
795
|
+
export const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Closed enum of `trigger` values a caller may explicitly pass to
|
|
799
|
+
* {@link runReconcile} (issue #1201 Part A / Discovery D8). Two of the three
|
|
800
|
+
* real call sites pinned `trigger` by PROSE inside a `.md` skill file —
|
|
801
|
+
* `skill` (`skills/reconcile/SKILL.md`) and `session-end`
|
|
802
|
+
* (`skills/session-end/phase-3-6-tail.md`) — where nothing enforced the
|
|
803
|
+
* string ever matched this list; only `phase-skip`
|
|
804
|
+
* (`scripts/lib/session-end/phase-skip.mjs`) pinned it in code. The two `.md`
|
|
805
|
+
* callers are migrated onto {@link runReconcileFromSkill} /
|
|
806
|
+
* {@link runReconcileAtSessionEnd} in this same change, so "prose sets the
|
|
807
|
+
* trigger" stops being possible for them at all.
|
|
808
|
+
*/
|
|
809
|
+
export const KNOWN_TRIGGERS = Object.freeze(['skill', 'session-end', 'phase-skip']);
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Reject an explicitly-passed `trigger` that is not in {@link KNOWN_TRIGGERS}
|
|
813
|
+
* — including the literal `'unknown'`, which is a DEFAULT this module mints
|
|
814
|
+
* for an ABSENT trigger, never a value a caller should pass on purpose. An
|
|
815
|
+
* absent trigger (`undefined`) is deliberately let through unchanged: it is
|
|
816
|
+
* legacy behaviour for a caller that has not yet been migrated onto a pinned
|
|
817
|
+
* wrapper, and `buildReconcilePayload` already defaults it to `'unknown'` so
|
|
818
|
+
* the per-trigger denominator in the ledger stays honest about which runs are
|
|
819
|
+
* unattributed rather than silently breaking them.
|
|
820
|
+
*
|
|
821
|
+
* BV-004 revisit trigger: `scripts/lib/session-end/phase-skip.mjs` is the one
|
|
822
|
+
* remaining caller passing `trigger: 'phase-skip'` as a hand-written literal
|
|
823
|
+
* rather than through {@link runReconcileFromPhaseSkip} (out of this task's
|
|
824
|
+
* file scope) — once every caller is migrated onto a pinned wrapper, flip the
|
|
825
|
+
* absent-trigger default below from "let it run" to a throw, closing the last
|
|
826
|
+
* legacy path.
|
|
827
|
+
*
|
|
828
|
+
* @param {unknown} trigger
|
|
829
|
+
* @throws {Error} when `trigger` is defined but not in `KNOWN_TRIGGERS`.
|
|
830
|
+
* @returns {void}
|
|
831
|
+
*/
|
|
832
|
+
export function assertKnownTrigger(trigger) {
|
|
833
|
+
if (trigger === undefined) return;
|
|
834
|
+
if (!KNOWN_TRIGGERS.includes(trigger)) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`runReconcile: invalid trigger ${JSON.stringify(trigger)} — must be one of: ` +
|
|
837
|
+
`${KNOWN_TRIGGERS.join(', ')} (or omitted entirely, for a not-yet-migrated caller).`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Clamp for the `reason` string on the abort path — a message can be long. */
|
|
843
|
+
const REASON_MAX_CHARS = 300;
|
|
844
|
+
|
|
845
|
+
/** The closed target enum `resolveEffectiveTargets` recognises. */
|
|
846
|
+
const KNOWN_TARGETS = ['repo-local', 'baseline'];
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Build the `orchestrator.reconcile.completed` payload from a finished run.
|
|
850
|
+
*
|
|
851
|
+
* Counter fields are written INCLUDING `0`: each was MEASURED over the whole
|
|
852
|
+
* run, so a written zero is the payload (same contract as the vault-mirror run
|
|
853
|
+
* event). The two absence-preserving exceptions are `store_records_dropped`
|
|
854
|
+
* (absent ⇒ the candidate store was never inspected — dryRun, empty
|
|
855
|
+
* short-circuit, error path) and `targets` (absent ⇒ the caller asserted no
|
|
856
|
+
* target list). `aborted`/`reason` appear only when the never-throws guard
|
|
857
|
+
* fired; their absence means "ran to the end", never "unknown".
|
|
858
|
+
*
|
|
859
|
+
* @param {ReconcileResult} result
|
|
860
|
+
* @param {{ trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
|
|
861
|
+
* @returns {Record<string, unknown>}
|
|
862
|
+
*/
|
|
863
|
+
function buildReconcilePayload(result, ctx) {
|
|
864
|
+
const summary = (result && result.summary) || {};
|
|
865
|
+
/** @type {Record<string, unknown>} */
|
|
866
|
+
const payload = {
|
|
867
|
+
trigger: typeof ctx.trigger === 'string' && ctx.trigger.trim() !== '' ? ctx.trigger : 'unknown',
|
|
868
|
+
dry_run: ctx.dryRun === true,
|
|
869
|
+
learnings_total: summary.totalLearnings ?? 0,
|
|
870
|
+
eligible: summary.eligible ?? 0,
|
|
871
|
+
proposals: summary.proposed ?? 0,
|
|
872
|
+
rejected: summary.rejected ?? 0,
|
|
873
|
+
capped: summary.capped ?? 0,
|
|
874
|
+
already_materialized: summary.alreadyMaterialized ?? 0,
|
|
875
|
+
written: summary.written === true,
|
|
876
|
+
duration_ms: ctx.durationMs,
|
|
877
|
+
};
|
|
878
|
+
// `targets` originates in operator-authored Session Config (`reconcile.targets`)
|
|
879
|
+
// and is unbounded there. Allowlisted to the CLOSED enum `resolveEffectiveTargets`
|
|
880
|
+
// recognises before it enters the ledger and the optional Clank webhook (Q2-F4):
|
|
881
|
+
// anything else is not a target this engine can act on, so recording it would
|
|
882
|
+
// be a verbatim echo of untrusted text, never a measurement. Omitted when empty.
|
|
883
|
+
const targets = Array.isArray(ctx.targets)
|
|
884
|
+
? [...new Set(ctx.targets.filter((t) => KNOWN_TARGETS.includes(t)))]
|
|
885
|
+
: [];
|
|
886
|
+
if (targets.length > 0) payload.targets = targets;
|
|
887
|
+
if (typeof summary.skipped === 'number') payload.store_records_dropped = summary.skipped;
|
|
888
|
+
if (typeof result?.error === 'string' && result.error !== '') {
|
|
889
|
+
payload.aborted = 'engine-error';
|
|
890
|
+
payload.reason = result.error.slice(0, REASON_MAX_CHARS);
|
|
891
|
+
}
|
|
892
|
+
return payload;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Record one reconcile run in the repo's event ledger — best-effort.
|
|
897
|
+
*
|
|
898
|
+
* Refuses the ambient `SO_PROJECT_DIR` destination when no `repoRoot` was
|
|
899
|
+
* given: most engine tests call `runReconcile` without one, and a fallback
|
|
900
|
+
* would append synthetic records to the operator's REAL fleet ledger on every
|
|
901
|
+
* `npm test` (#1119, `scripts/lib/express-path.mjs`). Diagnostics on stderr.
|
|
902
|
+
*
|
|
903
|
+
* @param {ReconcileResult} result
|
|
904
|
+
* @param {{ repoRoot?: string, trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
|
|
905
|
+
*/
|
|
906
|
+
async function emitReconcileCompleted(result, ctx) {
|
|
907
|
+
const { repoRoot } = ctx;
|
|
908
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
909
|
+
process.stderr.write(
|
|
910
|
+
`reconcile: skipped ${RECONCILE_EVENT} — no repoRoot given; ` +
|
|
911
|
+
'refusing the ambient SO_PROJECT_DIR destination (#1119).\n',
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const { emitEvent } = await import('../events.mjs');
|
|
916
|
+
await emitEvent(RECONCILE_EVENT, buildReconcilePayload(result, ctx), { repoRoot });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Public boundary: run the reconciliation pipeline and record the run.
|
|
921
|
+
*
|
|
922
|
+
* A thin WRAPPER, deliberately: the pipeline has three return points (empty
|
|
923
|
+
* short-circuit, normal tail, never-throws catch), and an inline emit would
|
|
924
|
+
* miss two of them — including the empty corpus and the error path, the two
|
|
925
|
+
* runs an operator most needs recorded (`.claude/rules/host-resources.md`
|
|
926
|
+
* § HR-105). Same shape as `runNarrativeMirror` + `mirrorNarrative` in
|
|
927
|
+
* `scripts/lib/vault-status/narrative-mirror.mjs`.
|
|
928
|
+
*
|
|
929
|
+
* The emit is wrapped in try/catch because `emitEvent` THROWS
|
|
930
|
+
* `EventValidationError` on an invalid record — without the catch, telemetry
|
|
931
|
+
* would break this function's never-throws contract. The pipeline's result is
|
|
932
|
+
* returned UNTOUCHED whether or not the ledger accepted the record.
|
|
933
|
+
*
|
|
934
|
+
* @param {Object} [params] - see {@link runReconcileInner}, plus:
|
|
935
|
+
* @param {'skill'|'session-end'|'phase-skip'} [params.trigger] - which caller
|
|
936
|
+
* invoked this run; recorded ALWAYS (default `'unknown'` when omitted) so
|
|
937
|
+
* the per-trigger denominator is complete. Not read by the pipeline.
|
|
938
|
+
* VALIDATED against {@link KNOWN_TRIGGERS} via {@link assertKnownTrigger}
|
|
939
|
+
* when explicitly passed — an unknown string, including the literal
|
|
940
|
+
* `'unknown'`, THROWS synchronously; an absent trigger is still let
|
|
941
|
+
* through unchanged (issue #1201 Part A — see `assertKnownTrigger`'s
|
|
942
|
+
* BV-004 revisit-trigger note for why the absent case is not also a
|
|
943
|
+
* throw yet). Prefer {@link runReconcileFromSkill},
|
|
944
|
+
* {@link runReconcileAtSessionEnd}, or {@link runReconcileFromPhaseSkip}
|
|
945
|
+
* over passing `trigger` here directly.
|
|
946
|
+
* @param {string[]} [params.targets] - the caller's effective target list
|
|
947
|
+
* (`resolveEffectiveTargets`); recorded when non-empty, omitted otherwise.
|
|
948
|
+
* Not read by the pipeline.
|
|
949
|
+
* @param {Object} [opts] - see {@link runReconcileInner}.
|
|
950
|
+
* @returns {Promise<ReconcileResult>}
|
|
951
|
+
*/
|
|
952
|
+
export async function runReconcile(params = {}, opts = {}) {
|
|
953
|
+
assertKnownTrigger(params.trigger);
|
|
954
|
+
const t0 = Date.now();
|
|
955
|
+
const result = await runReconcileInner(params, opts);
|
|
956
|
+
try {
|
|
957
|
+
await emitReconcileCompleted(result, {
|
|
958
|
+
repoRoot: params.repoRoot,
|
|
959
|
+
trigger: params.trigger,
|
|
960
|
+
targets: params.targets,
|
|
961
|
+
dryRun: params.dryRun === true || opts.dryRun === true,
|
|
962
|
+
durationMs: Date.now() - t0,
|
|
963
|
+
});
|
|
964
|
+
} catch {
|
|
965
|
+
// Best-effort telemetry — never the reason a reconcile run fails.
|
|
966
|
+
}
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Pin `trigger: 'skill'` in code for the `/reconcile` skill (Phase 2.3,
|
|
972
|
+
* `skills/reconcile/SKILL.md`) — the skill's own `.md` prose no longer sets
|
|
973
|
+
* the trigger string itself (issue #1201 Part A). Any `trigger` the caller
|
|
974
|
+
* passes in `params` is OVERWRITTEN; every other field forwards unchanged.
|
|
975
|
+
*
|
|
976
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
977
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
978
|
+
* @returns {Promise<ReconcileResult>}
|
|
979
|
+
*/
|
|
980
|
+
export async function runReconcileFromSkill(params = {}, opts = {}) {
|
|
981
|
+
return runReconcile({ ...params, trigger: 'skill' }, opts);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Pin `trigger: 'session-end'` in code for session-end Phase 3.6.8
|
|
986
|
+
* (`skills/session-end/phase-3-6-tail.md`) — see
|
|
987
|
+
* {@link runReconcileFromSkill} for the rationale and contract.
|
|
988
|
+
*
|
|
989
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
990
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
991
|
+
* @returns {Promise<ReconcileResult>}
|
|
992
|
+
*/
|
|
993
|
+
export async function runReconcileAtSessionEnd(params = {}, opts = {}) {
|
|
994
|
+
return runReconcile({ ...params, trigger: 'session-end' }, opts);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Pin `trigger: 'phase-skip'` in code for the session-end skip-plan
|
|
999
|
+
* aggregator (`scripts/lib/session-end/phase-skip.mjs`, the highest-volume
|
|
1000
|
+
* probe-only caller) — see {@link runReconcileFromSkill} for the rationale
|
|
1001
|
+
* and contract. NOT YET wired into `phase-skip.mjs` itself, which is out of
|
|
1002
|
+
* this task's file scope and still passes `trigger: 'phase-skip'` as a
|
|
1003
|
+
* hand-written literal directly to `runReconcile`; exported here so that
|
|
1004
|
+
* migration is a one-line import swap (see `assertKnownTrigger`'s BV-004
|
|
1005
|
+
* revisit-trigger note).
|
|
1006
|
+
*
|
|
1007
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
1008
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
1009
|
+
* @returns {Promise<ReconcileResult>}
|
|
1010
|
+
*/
|
|
1011
|
+
export async function runReconcileFromPhaseSkip(params = {}, opts = {}) {
|
|
1012
|
+
return runReconcile({ ...params, trigger: 'phase-skip' }, opts);
|
|
1013
|
+
}
|
|
@@ -918,6 +918,28 @@ export function suggestForScopeViolation(relPath, allowedCsv) {
|
|
|
918
918
|
);
|
|
919
919
|
}
|
|
920
920
|
|
|
921
|
+
/**
|
|
922
|
+
* Prefix that marks an aggregate-sidecar record as a PEER SESSION's declared
|
|
923
|
+
* scope rather than one of this wave's own agents (#1195).
|
|
924
|
+
*
|
|
925
|
+
* SSOT: this constant lives here — in the module both the `--union` helper and
|
|
926
|
+
* `hooks/post-bash-write-verify.mjs` already import — so the union exclusion
|
|
927
|
+
* below and the hook's peer-write notice can never disagree about what a peer
|
|
928
|
+
* record IS.
|
|
929
|
+
*/
|
|
930
|
+
export const PEER_RECORD_PREFIX = 'peer-session-';
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Is this record id a PEER SESSION's record (see {@link PEER_RECORD_PREFIX})?
|
|
934
|
+
* Fail-closed: a non-string id is not a peer record.
|
|
935
|
+
*
|
|
936
|
+
* @param {unknown} id
|
|
937
|
+
* @returns {boolean}
|
|
938
|
+
*/
|
|
939
|
+
export function isPeerRecordId(id) {
|
|
940
|
+
return typeof id === 'string' && id.startsWith(PEER_RECORD_PREFIX);
|
|
941
|
+
}
|
|
942
|
+
|
|
921
943
|
/**
|
|
922
944
|
* Merge many agents' declared file scopes into ONE deduplicated, order-stable
|
|
923
945
|
* list — the mechanical form of "allowedPaths is the UNION of all agent file
|
|
@@ -943,6 +965,19 @@ export function suggestForScopeViolation(relPath, allowedCsv) {
|
|
|
943
965
|
* non-array / non-object members and non-string, empty entries are skipped.
|
|
944
966
|
* Pure, sync, no I/O — hook-safe per the module header.
|
|
945
967
|
*
|
|
968
|
+
* PEER RECORDS ARE EXCLUDED (#1195 follow-through). A record whose id starts
|
|
969
|
+
* with `peer-session-` declares a territory NO agent of this wave may write —
|
|
970
|
+
* it exists so a peer's paths take part in the DISJOINTNESS check and so
|
|
971
|
+
* `hooks/post-bash-write-verify.mjs` can name a peer write instead of alarming
|
|
972
|
+
* about it. Unioning it into `allowedPaths` would do the exact inverse: Gate 7
|
|
973
|
+
* (`hooks/enforce-scope.mjs`) would GRANT every agent of the wave write access
|
|
974
|
+
* to the peer's files, and the hook's peer branch would become dead code (a
|
|
975
|
+
* peer path can only reach it while it is OUTSIDE `allowedPaths`). The
|
|
976
|
+
* exclusion lives HERE — the one helper `--union` runs — rather than in the
|
|
977
|
+
* CLI, so every consumer of the union inherits it.
|
|
978
|
+
* `findScopeCollisions` deliberately does NOT filter: a peer/agent path
|
|
979
|
+
* collision is a real collision and must surface.
|
|
980
|
+
*
|
|
946
981
|
* @param {Array<string[]|{id?: string, files?: string[]}>} scopes
|
|
947
982
|
* @returns {string[]} deduplicated union in first-seen order
|
|
948
983
|
*/
|
|
@@ -954,6 +989,7 @@ export function unionFileScopes(scopes) {
|
|
|
954
989
|
let files = null;
|
|
955
990
|
if (Array.isArray(scope)) files = scope;
|
|
956
991
|
else if (scope !== null && typeof scope === 'object' && Array.isArray(scope.files)) {
|
|
992
|
+
if (isPeerRecordId(scope.id)) continue;
|
|
957
993
|
files = scope.files;
|
|
958
994
|
}
|
|
959
995
|
if (files === null) continue;
|
|
@@ -153,18 +153,33 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
|
|
|
153
153
|
|
|
154
154
|
let mode = null;
|
|
155
155
|
let semanticFromLock = null;
|
|
156
|
-
|
|
157
|
-
//
|
|
156
|
+
// #1167 — the SECOND semantic bridge. `orchestrator.session.ended` carries
|
|
157
|
+
// `semantic_session_id` alongside the raw UUID since #1068 AC1, but nothing
|
|
158
|
+
// read it: a session that LOST the lock-acquire race emits no lock.acquired,
|
|
159
|
+
// so the lock bridge above resolved null and the caller fell through to the
|
|
160
|
+
// synthetic-id mint — writing a SECOND `abandoned` stub for a session the
|
|
161
|
+
// SessionEnd hook had already recorded under its semantic id. Measured
|
|
162
|
+
// 2026-09-02 @ c3ab480: 8 such duplicate pairs in sessions.jsonl.
|
|
163
|
+
let semanticFromEvents = null;
|
|
164
|
+
|
|
165
|
+
// First pass — bridge the UUID set + carry mode + semantic id. lock.acquired
|
|
166
|
+
// is the original bridge; session.ended is the #1167 addition.
|
|
158
167
|
for (const ev of events) {
|
|
159
|
-
|
|
168
|
+
const isLock = ev.event === EVENT_LOCK_ACQUIRED;
|
|
169
|
+
const isEnded = ev.event === EVENT_ENDED && typeof ev.semantic_session_id === 'string';
|
|
170
|
+
if (!isLock && !isEnded) continue;
|
|
160
171
|
const matchesUuid = isUuid(sessionId) && ev.session_id === sessionId;
|
|
161
172
|
const matchesSemantic =
|
|
162
173
|
(semanticSessionId && ev.semantic_session_id === semanticSessionId) ||
|
|
163
174
|
(!isUuid(sessionId) && sessionId && ev.semantic_session_id === sessionId);
|
|
164
175
|
if (!matchesUuid && !matchesSemantic) continue;
|
|
165
176
|
if (typeof ev.session_id === 'string') uuids.add(ev.session_id);
|
|
166
|
-
if (
|
|
167
|
-
|
|
177
|
+
if (isLock) {
|
|
178
|
+
if (typeof ev.mode === 'string') mode = ev.mode;
|
|
179
|
+
if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
|
|
180
|
+
} else {
|
|
181
|
+
semanticFromEvents = ev.semantic_session_id;
|
|
182
|
+
}
|
|
168
183
|
}
|
|
169
184
|
|
|
170
185
|
// Second pass — started + terminal timestamps from every matched UUID.
|
|
@@ -200,7 +215,18 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
|
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
|
|
203
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
uuids,
|
|
220
|
+
mode,
|
|
221
|
+
semanticFromLock,
|
|
222
|
+
semanticFromEvents,
|
|
223
|
+
startedAt,
|
|
224
|
+
branch,
|
|
225
|
+
project,
|
|
226
|
+
lastTerminalMs,
|
|
227
|
+
earliestMs,
|
|
228
|
+
lastEventMs,
|
|
229
|
+
};
|
|
204
230
|
}
|
|
205
231
|
|
|
206
232
|
/**
|
|
@@ -256,7 +282,7 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
|
|
|
256
282
|
* this record replaces. It is emitted only when non-null, so every existing
|
|
257
283
|
* record shape is byte-identical to before.
|
|
258
284
|
*/
|
|
259
|
-
function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'abandoned', backfillSource = 'events-jsonl', supersedes = null }) {
|
|
285
|
+
function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'abandoned', backfillSource = 'events-jsonl', supersedes = null, rawSessionId = null }) {
|
|
260
286
|
const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
|
|
261
287
|
const startedMs = Date.parse(startedIso);
|
|
262
288
|
// completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
|
|
@@ -329,6 +355,13 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
|
|
|
329
355
|
// "historische Stub-Provenance bleibt erhalten"). Readers resolve one
|
|
330
356
|
// canonical state per id by taking the NEWEST record for that id.
|
|
331
357
|
if (typeof supersedes === 'string' && supersedes.length > 0) record.supersedes = supersedes;
|
|
358
|
+
// #1167 — the harness UUID this record was reconstructed from, when known.
|
|
359
|
+
// Additive and optional (the schema validates unknown keys pass-through, see
|
|
360
|
+
// session-schema/validator.mjs `_validateOptionalFields`): it is the ONLY key
|
|
361
|
+
// that lets a reader join a semantic record back to its raw uuid. Measured
|
|
362
|
+
// 2026-09-02 @ c3ab480: 0 of 286 existing records carry it, which is exactly
|
|
363
|
+
// why the two backfill writers could not see each other's work.
|
|
364
|
+
if (typeof rawSessionId === 'string' && rawSessionId.length > 0) record.raw_session_id = rawSessionId;
|
|
332
365
|
return record;
|
|
333
366
|
}
|
|
334
367
|
|
|
@@ -535,8 +568,11 @@ export async function backfillAbandonedSession({
|
|
|
535
568
|
|
|
536
569
|
// -- Resolve a deferred id from the lock bridge or a synthetic mint ------
|
|
537
570
|
if (recordId === null) {
|
|
538
|
-
|
|
539
|
-
|
|
571
|
+
// Prefer the lock bridge (it also carries `mode`), then the #1167
|
|
572
|
+
// session.ended bridge. Only when NEITHER attests a semantic id do we
|
|
573
|
+
// mint a synthetic one — that fallback was the duplicate-stub source.
|
|
574
|
+
if (gathered.semanticFromLock || gathered.semanticFromEvents) {
|
|
575
|
+
recordId = gathered.semanticFromLock || gathered.semanticFromEvents;
|
|
540
576
|
} else {
|
|
541
577
|
// No semantic bridge — mint a synthetic id. Both components are STABLE
|
|
542
578
|
// across re-runs so dedupe/marker suppress a double write (idempotency
|
|
@@ -619,7 +655,13 @@ export async function backfillAbandonedSession({
|
|
|
619
655
|
}
|
|
620
656
|
|
|
621
657
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ---
|
|
622
|
-
const record = synthesizeRecord({
|
|
658
|
+
const record = synthesizeRecord({
|
|
659
|
+
recordId,
|
|
660
|
+
synthetic,
|
|
661
|
+
gathered,
|
|
662
|
+
nowMs,
|
|
663
|
+
rawSessionId: isUuid(sessionId) ? sessionId : null,
|
|
664
|
+
});
|
|
623
665
|
let validated;
|
|
624
666
|
try {
|
|
625
667
|
validated = validateSession(record);
|
|
@@ -845,6 +887,13 @@ export async function backfillCompletedFromStateMd({
|
|
|
845
887
|
status: 'completed',
|
|
846
888
|
backfillSource: 'state-md-completed',
|
|
847
889
|
supersedes,
|
|
890
|
+
// #1167 — the abandoned path stamps this; so must the authoritative
|
|
891
|
+
// one, or the join key exists on exactly the weaker half of the pair.
|
|
892
|
+
// `gathered.uuids` is bridged from lock.acquired / session.ended, so it
|
|
893
|
+
// normally holds EXACTLY the one uuid this semantic id ran under. Two
|
|
894
|
+
// (or zero) means the bridge is ambiguous — omit rather than guess, the
|
|
895
|
+
// same fail-quiet posture as `isUuid(sessionId) ? sessionId : null`.
|
|
896
|
+
rawSessionId: gathered.uuids?.size === 1 ? [...gathered.uuids][0] : null,
|
|
848
897
|
});
|
|
849
898
|
let validated;
|
|
850
899
|
try {
|
|
@@ -32,6 +32,28 @@
|
|
|
32
32
|
* - Lock takes precedence over registry (more detail per session); registry
|
|
33
33
|
* supplements missing per-worktree entries (e.g., when the hook ran but
|
|
34
34
|
* the prose Phase 1.2 acquire was skipped).
|
|
35
|
+
* - A live local session.lock does NOT supersede same-repo registry entries
|
|
36
|
+
* with a different raw session_id (GH#67 candidate fix, measured 2026-09-02
|
|
37
|
+
* and NOT adopted): the session lock is advisory, so a second session can
|
|
38
|
+
* and demonstrably does run in the same working copy without holding it.
|
|
39
|
+
* Filtering those entries out hides genuine same-working-copy peers — the
|
|
40
|
+
* #1085 semantic-alias boundary (tests/integration/session-identity-
|
|
41
|
+
* boundaries.test.mjs) and 7 peer-discovery cases pin exactly that
|
|
42
|
+
* visibility. Any GH#67 fix must discriminate finished-but-fresh entries
|
|
43
|
+
* some other way; do not re-attempt lock-ownership supersession here.
|
|
44
|
+
* See ADR-0014 (docs/adr/0014-peer-visibility-under-advisory-lock.md) for
|
|
45
|
+
* the annotate-never-filter decision and the 9-test refutation of the
|
|
46
|
+
* filtering alternative.
|
|
47
|
+
* - The adopted GH#67 shape is therefore ADDITIVE ANNOTATION, never a filter.
|
|
48
|
+
* Registry-sourced sessions carry `registryOnly: true` plus
|
|
49
|
+
* `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` means: a LIVE
|
|
50
|
+
* lock at this repoRoot is owned by a different raw session_id than this
|
|
51
|
+
* registry entry. It is a HINT, not a verdict — the lock is advisory, so
|
|
52
|
+
* the entry may still be a live session that lost the acquire race (#1085
|
|
53
|
+
* contract). Consumers deciding a worktree-PROMOTION_OFFER downgrade such
|
|
54
|
+
* a peer to an advisory line (GH#67); consumers counting or displaying
|
|
55
|
+
* peers keep it. Lock-sourced sessions carry none of the three fields, so
|
|
56
|
+
* their objects stay byte-identical to the pre-GH#67 shape.
|
|
35
57
|
*
|
|
36
58
|
* Timeout + A1 fallback:
|
|
37
59
|
* - listWorktrees() is raced against DEFAULT_DISCOVERY_TIMEOUT_MS (2 s).
|
|
@@ -93,10 +115,24 @@ function sessionFromLock(lock, worktreePath, branch = '') {
|
|
|
93
115
|
* does not record per-worktree paths — only repo-path-hashes. Branch is
|
|
94
116
|
* passed through from the registry entry when available.
|
|
95
117
|
*
|
|
118
|
+
* GH#67 annotation (additive, never a filter — see the module header): every
|
|
119
|
+
* registry-sourced session carries `registryOnly: true` and the pair
|
|
120
|
+
* `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` says a LIVE lock at
|
|
121
|
+
* this repoRoot is owned by a DIFFERENT raw session_id than this entry — a
|
|
122
|
+
* HINT that the entry may be a finished-but-still-fresh task (the GH#67 case:
|
|
123
|
+
* Codex emits no SessionEnd, so the registry entry outlives the task by up to
|
|
124
|
+
* `freshnessMin`). It is not a verdict: the lock is advisory, so the entry may
|
|
125
|
+
* equally be a live session that never acquired it (#1085). Consumers deciding
|
|
126
|
+
* a worktree PROMOTION_OFFER downgrade such a peer to an advisory line;
|
|
127
|
+
* consumers counting or displaying peers keep it unchanged.
|
|
128
|
+
*
|
|
96
129
|
* @param {object} entry Registry entry.
|
|
97
130
|
* @param {string} repoRoot Discovery repoRoot (used as worktreePath fallback).
|
|
131
|
+
* @param {object} [ctx]
|
|
132
|
+
* @param {string|null} [ctx.lockOwnerId] Raw session_id owning a LIVE lock at
|
|
133
|
+
* repoRoot, or null when there is no live local lock.
|
|
98
134
|
*/
|
|
99
|
-
function sessionFromRegistryEntry(entry, repoRoot) {
|
|
135
|
+
function sessionFromRegistryEntry(entry, repoRoot, { lockOwnerId = null } = {}) {
|
|
100
136
|
return {
|
|
101
137
|
worktreePath: repoRoot,
|
|
102
138
|
sessionId: entry.session_id,
|
|
@@ -111,6 +147,11 @@ function sessionFromRegistryEntry(entry, repoRoot) {
|
|
|
111
147
|
// single machine, so consumers comparing hosts need the normalised form.
|
|
112
148
|
host_id: stableHostname(),
|
|
113
149
|
branch: typeof entry.branch === 'string' ? entry.branch : '',
|
|
150
|
+
// GH#67 additive annotation (see the JSDoc above). Lock-sourced sessions
|
|
151
|
+
// deliberately carry none of these three fields.
|
|
152
|
+
registryOnly: true,
|
|
153
|
+
lockSuperseded: lockOwnerId !== null && entry.session_id !== lockOwnerId,
|
|
154
|
+
lockOwnerId,
|
|
114
155
|
};
|
|
115
156
|
}
|
|
116
157
|
|
|
@@ -177,7 +218,10 @@ function dedupeBySessionId(sessions) {
|
|
|
177
218
|
* @param {Function} [opts.registryReader] DI hook replacing readRegistry() for tests.
|
|
178
219
|
* @param {number} [opts.freshnessMin=15] Registry-entry freshness threshold in minutes.
|
|
179
220
|
* @param {number} [opts.now] ms-since-epoch (test seam for heartbeat freshness).
|
|
180
|
-
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}>>}
|
|
221
|
+
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string,registryOnly?:boolean,lockSuperseded?:boolean,lockOwnerId?:string|null}>>}
|
|
222
|
+
* The last three fields appear ONLY on registry-sourced sessions (GH#67
|
|
223
|
+
* annotation — see `sessionFromRegistryEntry`); lock-sourced sessions omit
|
|
224
|
+
* them entirely.
|
|
181
225
|
*/
|
|
182
226
|
export async function discoverActiveSessions(repoRoot, opts = {}) {
|
|
183
227
|
const listWorktreesFn = opts.listWorktreesImpl ?? listWorktrees;
|
|
@@ -232,10 +276,20 @@ export async function discoverActiveSessions(repoRoot, opts = {}) {
|
|
|
232
276
|
try {
|
|
233
277
|
const allEntries = await registryReaderFn();
|
|
234
278
|
const myRepoHash = repoPathHash(repoRoot);
|
|
279
|
+
// GH#67: who (if anyone) holds a LIVE lock at this repoRoot right now.
|
|
280
|
+
// Read once, outside the map — it is a property of the repo, not of an
|
|
281
|
+
// entry. A stale lock proves nothing, so it yields null.
|
|
282
|
+
const localLock = readLock({ repoRoot });
|
|
283
|
+
const lockOwnerId = (
|
|
284
|
+
localLock
|
|
285
|
+
&& isLockLive(localLock, nowMs)
|
|
286
|
+
&& typeof localLock.session_id === 'string'
|
|
287
|
+
&& localLock.session_id
|
|
288
|
+
) ? localLock.session_id : null;
|
|
235
289
|
registrySessions = allEntries
|
|
236
290
|
.filter((e) => e && e.repo_path_hash === myRepoHash)
|
|
237
291
|
.filter((e) => isRegistryEntryFresh(e, { freshnessMin, now: nowMs }))
|
|
238
|
-
.map((e) => sessionFromRegistryEntry(e, repoRoot));
|
|
292
|
+
.map((e) => sessionFromRegistryEntry(e, repoRoot, { lockOwnerId }));
|
|
239
293
|
} catch {
|
|
240
294
|
// Registry read failure → keep going with lock-only results.
|
|
241
295
|
registrySessions = [];
|
|
@@ -55,7 +55,7 @@ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
|
55
55
|
import { shouldDispatchAutoDream } from '../auto-dream.mjs';
|
|
56
56
|
import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
|
|
57
57
|
import { readSkillInvocations } from '../skill-invocations-schema.mjs';
|
|
58
|
-
import {
|
|
58
|
+
import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
|
|
59
59
|
import { resolveMemoryDir } from '../memory-paths.mjs';
|
|
60
60
|
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
@@ -285,7 +285,7 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
285
285
|
if (!existsSync(learningsPath)) {
|
|
286
286
|
return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
|
|
287
287
|
}
|
|
288
|
-
const { proposals, summary, error } = await
|
|
288
|
+
const { proposals, summary, error } = await runReconcileFromPhaseSkip({
|
|
289
289
|
repoRoot,
|
|
290
290
|
ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
|
|
291
291
|
minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
|
|
@@ -120,6 +120,61 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
|
120
120
|
return ids;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The ids that name this session and are PROCESS-LOCAL — tiers 1 and 2 only,
|
|
125
|
+
* never the lock and never STATE.md.
|
|
126
|
+
*
|
|
127
|
+
* A sibling of {@link readOwnSessionIds}, not a replacement: the two answer
|
|
128
|
+
* different questions and the difference is the whole point.
|
|
129
|
+
*
|
|
130
|
+
* - `readOwnSessionIds()` answers *"could this id name me?"* and unions three
|
|
131
|
+
* tiers, the third of which IS `session.lock`. That union is correct when
|
|
132
|
+
* the thing being classified was written by some OTHER process (a wave-scope
|
|
133
|
+
* manifest), because every id this process can legitimately claim counts.
|
|
134
|
+
* - This function answers *"which process is emitting right now?"*, and for
|
|
135
|
+
* that question the lock is **vacuous**: when the candidate ids under
|
|
136
|
+
* judgement are the lock's OWN values, a membership test against a set that
|
|
137
|
+
* contains the lock matches by construction — a peer-owned lock would
|
|
138
|
+
* classify as `own` 100% of the time.
|
|
139
|
+
*
|
|
140
|
+
* **STATE.md is excluded for the same reason, and this is the #1177-FX1 fix.**
|
|
141
|
+
* `.claude/STATE.md` is a SHARED working-copy artefact written by whichever
|
|
142
|
+
* session owns the working copy — normally the lock holder. So when a peer holds
|
|
143
|
+
* the lock, the peer also wrote STATE.md, and the two "independent" witnesses
|
|
144
|
+
* agree with each other about the PEER. Unioning a shared-file witness with a
|
|
145
|
+
* process-local one lets the weaker witness carry the verdict while a
|
|
146
|
+
* disagreeing process-local id cannot veto it (measured: lock=peer +
|
|
147
|
+
* STATE.md=peer + `CLAUDE_CODE_SESSION_ID`=me stamped the PEER's ids). Tiering
|
|
148
|
+
* rather than unioning is the fix — a better signal REPLACES a worse one
|
|
149
|
+
* (`.claude/rules/host-resources.md` § HR-102).
|
|
150
|
+
*
|
|
151
|
+
* Never throws.
|
|
152
|
+
*
|
|
153
|
+
* @param {{ env?: object, hookInput?: object|null }} [opts]
|
|
154
|
+
* @param {object} [opts.env=process.env] — injectable for tests.
|
|
155
|
+
* @param {object|null} [opts.hookInput=null] — the harness's statement about
|
|
156
|
+
* THIS invocation, when the caller is a hook.
|
|
157
|
+
* @returns {string[]} possibly EMPTY — an empty result means "this process
|
|
158
|
+
* cannot prove who it is", which callers must treat as unprovable rather
|
|
159
|
+
* than as a match.
|
|
160
|
+
*/
|
|
161
|
+
export function readProcessLocalSessionIds({ env = process.env, hookInput = null } = {}) {
|
|
162
|
+
const ids = [];
|
|
163
|
+
const add = (value) => {
|
|
164
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
165
|
+
if (trimmed && !ids.includes(trimmed)) ids.push(trimmed);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Tier 1 — the harness's statement about THIS invocation.
|
|
169
|
+
if (hookInput && typeof hookInput === 'object') {
|
|
170
|
+
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
171
|
+
}
|
|
172
|
+
// Tier 2 — process-scoped env var.
|
|
173
|
+
add(env?.CLAUDE_CODE_SESSION_ID);
|
|
174
|
+
|
|
175
|
+
return ids;
|
|
176
|
+
}
|
|
177
|
+
|
|
123
178
|
/**
|
|
124
179
|
* Decide whether a wave-scope manifest belongs to THIS session.
|
|
125
180
|
*
|
|
@@ -141,7 +196,13 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
|
141
196
|
*
|
|
142
197
|
* @param {unknown} scope — parsed wave-scope manifest (any shape; a non-object
|
|
143
198
|
* simply yields no ids, hence `'unknown'`).
|
|
144
|
-
* @param {Set<string>} ownIds — from
|
|
199
|
+
* @param {Set<string>} ownIds — from EITHER producer, depending on what is
|
|
200
|
+
* being judged: {@link readOwnSessionIds} when every id this process could
|
|
201
|
+
* legitimately claim counts, or `new Set(`{@link readProcessLocalSessionIds}
|
|
202
|
+
* `(...))` when the lock tier would match vacuously — which is the case for a
|
|
203
|
+
* wave-scope manifest in a checkout shared by two sessions (#1194). Note the
|
|
204
|
+
* latter returns a `string[]`: a bare array is NOT a Set and folds to the
|
|
205
|
+
* empty set below, yielding `'unknown'` for every manifest.
|
|
145
206
|
* @returns {{ verdict: 'own'|'foreign'|'unknown', manifestIds: string[] }}
|
|
146
207
|
*/
|
|
147
208
|
export function classifyManifestSession(scope, ownIds) {
|
|
@@ -134,7 +134,7 @@ export const LOCK_RELEASED_EVENT = 'orchestrator.session.lock.released';
|
|
|
134
134
|
* lock owned by someone else, an unparseable lock, a registry entry pinned to
|
|
135
135
|
* a different root, a registry unlink that failed, a contradictory owner
|
|
136
136
|
* proof, or a filesystem error. Callers WARN on it and continue; see the
|
|
137
|
-
* `
|
|
137
|
+
* `enterWorktree: leaveSourceRoot: <reason>` contract in
|
|
138
138
|
* `skills/_shared/parallel-aware-auq.md`.
|
|
139
139
|
*
|
|
140
140
|
* `reason` is present whenever something other than a full clean teardown
|