session-orchestrator 3.23.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. package/skills/wave-executor/wave-loop.md +43 -5
@@ -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. A per-learning emit/render failure
38
- * degrades to a recorded rejection (never a crash); any unexpected top-level
39
- * error returns a zeroed result with an `error` field.
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
- export async function runReconcile(
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
- // First pass lock.acquired bridges the UUID set + carries mode + semantic.
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
- if (ev.event !== EVENT_LOCK_ACQUIRED) continue;
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 (typeof ev.mode === 'string') mode = ev.mode;
167
- if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
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 { uuids, mode, semanticFromLock, startedAt, branch, project, lastTerminalMs, earliestMs, lastEventMs };
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
- if (gathered.semanticFromLock) {
539
- recordId = gathered.semanticFromLock;
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({ recordId, synthetic, gathered, nowMs });
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 { runReconcile, resolveEffectiveTargets } from '../reconcile/engine.mjs';
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 runReconcile({
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 {@link readOwnSessionIds}.
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
- * `parallel-aware: leaveSourceRoot: <reason>` contract in
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