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
@@ -63,6 +63,7 @@ import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
63
63
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
64
64
  import { resolveCanonicalSuffixes } from './lib/named-vault-resolver.mjs';
65
65
  import { loadOwnerConfig } from './lib/owner-yaml.mjs';
66
+ import { canonicalizeSessions } from './lib/sessions-canonical.mjs';
66
67
 
67
68
  // ── Canonical-vault helpers (#600 D2 / #607 D2) ────────────────────────────────
68
69
  // These are module-level (above the CLI bootstrap) so the module is import-safe
@@ -489,24 +490,24 @@ async function main() {
489
490
  qualityMinConfidence,
490
491
  };
491
492
 
492
- for (const line of lines) {
493
- lineNum++;
494
- const trimmed = line.trim();
495
- if (!trimmed) continue;
496
- runState.total++;
497
-
498
- let entry;
499
- try {
500
- entry = JSON.parse(trimmed);
501
- } catch (err) {
502
- process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${err.message}\n`);
503
- // Close the run out BEFORE exiting: `process.exit` runs no `finally`, so
504
- // without this the abort is the one outcome that leaves no run record —
505
- // exactly the shape reserved for a broken emitter.
506
- await finishRun('malformed-json');
507
- process.exit(1);
508
- }
509
-
493
+ /**
494
+ * Dispatch one already-parsed entry to its processor and account for the
495
+ * result. Extracted (#1186c) so the `--kind session` path below can call it
496
+ * AFTER a whole-file dedup pass instead of once per raw line; every branch
497
+ * is byte-identical to the pre-#1186c per-line loop body.
498
+ * @param {unknown} entry — parsed JSONL value (usually an object; a bare
499
+ * `null`/primitive line is a real shape this must keep handling, see the
500
+ * #1186c session branch below for why it is never filtered out here).
501
+ * @param {number} entryLineNum — 1-based JSONL line number, or (for the
502
+ * `--kind session` dedup path) the line of the record that WON the
503
+ * collapse. `_lineNum` is read ONLY for telemetry (process.mjs
504
+ * `emitEntryAction` `line:` on the per-entry ledger event), never to
505
+ * derive content process.mjs itself tolerates a non-finite value by
506
+ * suppressing just that one ledger record, but every call site here
507
+ * always supplies a real line number.
508
+ * @returns {Promise<void>}
509
+ */
510
+ async function dispatchEntry(entry, entryLineNum) {
510
511
  try {
511
512
  // Both processors return the `action` string they emitted (every one of
512
513
  // their exit paths is an `emitAction` call), so the tally needs no second
@@ -514,8 +515,8 @@ async function main() {
514
515
  // stale the first time a branch is added.
515
516
  const action =
516
517
  kind === 'learning'
517
- ? await processLearning(entry, lineNum, ctx)
518
- : await processSession(entry, lineNum, ctx);
518
+ ? await processLearning(entry, entryLineNum, ctx)
519
+ : await processSession(entry, entryLineNum, ctx);
519
520
  tally(action);
520
521
  } catch (err) {
521
522
  // Validation errors (missing required fields) → per-entry skip, not a global failure
@@ -530,13 +531,13 @@ async function main() {
530
531
  await emitMirrorEvent({
531
532
  action: 'skipped-invalid',
532
533
  kind,
533
- line: lineNum,
534
+ line: entryLineNum,
534
535
  recordId: entryId,
535
536
  skipClass: 'validation',
536
537
  reason: err.message,
537
538
  dryRun,
538
539
  });
539
- continue;
540
+ return;
540
541
  }
541
542
  // #718: discriminate genuine filesystem/system errors (which must still
542
543
  // abort the whole run — a partially-written vault is worse than a loud
@@ -552,7 +553,7 @@ async function main() {
552
553
  (typeof err.code === 'string' && err.code.length > 0) || Boolean(err.syscall);
553
554
  if (!isSystemError) {
554
555
  process.stderr.write(
555
- `vault-mirror: mapper crash on line ${lineNum} (${err.message}) — record skipped\n`,
556
+ `vault-mirror: mapper crash on line ${entryLineNum} (${err.message}) — record skipped\n`,
556
557
  );
557
558
  const entryId = entry?.id ?? entry?.session_id ?? null;
558
559
  process.stdout.write(
@@ -569,21 +570,105 @@ async function main() {
569
570
  await emitMirrorEvent({
570
571
  action: 'skipped-invalid',
571
572
  kind,
572
- line: lineNum,
573
+ line: entryLineNum,
573
574
  recordId: entryId,
574
575
  skipClass: 'mapper-crash',
575
576
  reason: err.message,
576
577
  dryRun,
577
578
  });
578
- continue;
579
+ return;
579
580
  }
580
581
  // Unexpected filesystem errors → fatal
581
- process.stderr.write(`vault-mirror: filesystem error on line ${lineNum}: ${err.message}\n`);
582
+ process.stderr.write(`vault-mirror: filesystem error on line ${entryLineNum}: ${err.message}\n`);
582
583
  await finishRun('filesystem-error');
583
584
  process.exit(2);
584
585
  }
585
586
  }
586
587
 
588
+ // #1186c: for `--kind session`, every parsed entry is buffered here instead
589
+ // of dispatched inline — the dedup pass below needs the WHOLE file before it
590
+ // can tell which of several same-`session_id` lines is the winner. `--kind
591
+ // learning` is unaffected: it still dispatches per line, inline, immediately
592
+ // below (a malformed line further down the file must not undo an already-
593
+ // dispatched learning — pinned by the existing `total: 2, created: 1` abort
594
+ // test in tests/unit/vault-mirror.test.mjs).
595
+ const sessionEntries = [];
596
+ const sessionLineNums = [];
597
+
598
+ for (const line of lines) {
599
+ lineNum++;
600
+ const trimmed = line.trim();
601
+ if (!trimmed) continue;
602
+ runState.total++;
603
+
604
+ let entry;
605
+ try {
606
+ entry = JSON.parse(trimmed);
607
+ } catch (err) {
608
+ process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${err.message}\n`);
609
+ // Close the run out BEFORE exiting: `process.exit` runs no `finally`, so
610
+ // without this the abort is the one outcome that leaves no run record —
611
+ // exactly the shape reserved for a broken emitter.
612
+ await finishRun('malformed-json');
613
+ process.exit(1);
614
+ }
615
+
616
+ if (kind === 'session') {
617
+ sessionEntries.push(entry);
618
+ sessionLineNums.push(lineNum);
619
+ continue;
620
+ }
621
+
622
+ await dispatchEntry(entry, lineNum);
623
+ }
624
+
625
+ if (kind === 'session') {
626
+ // Only an OBJECT entry carrying a non-empty `session_id` is eligible for
627
+ // the dedup collapse — the same predicate canonicalizeSessions itself uses
628
+ // internally (sessions-canonical.mjs `isRecordObject` + `isNonEmptyString`,
629
+ // not exported, so re-stated here rather than reached into). Everything
630
+ // else — a bare `null`/primitive JSONL line, or a legacy record with no
631
+ // `session_id` field — is dispatched EXACTLY as before: unaffected, in
632
+ // original file order, through the SAME validation/mapper-crash paths
633
+ // process.mjs already has for those shapes. Two regression-guard tests in
634
+ // tests/unit/vault-mirror.test.mjs depend on this (a bare `null` line and a
635
+ // legacy `session`-keyed record both still reach processSession() and its
636
+ // existing error handling, never silently vanish into the collapse).
637
+ const isIdentifiable = (e) =>
638
+ e !== null &&
639
+ typeof e === 'object' &&
640
+ !Array.isArray(e) &&
641
+ typeof e.session_id === 'string' &&
642
+ e.session_id.length > 0;
643
+ const identifiable = sessionEntries.filter(isIdentifiable);
644
+ // canonicalizeSessions never clones — the survivors are the SAME object
645
+ // references as in `sessionEntries`, so reference identity below is exact,
646
+ // never a guess (scripts/lib/sessions-canonical.mjs header, "RULE ORDER").
647
+ const survivors = new Set(canonicalizeSessions(identifiable));
648
+
649
+ for (let i = 0; i < sessionEntries.length; i++) {
650
+ const entry = sessionEntries[i];
651
+ if (isIdentifiable(entry) && !survivors.has(entry)) {
652
+ // A losing duplicate: an earlier line whose `session_id` a LATER line
653
+ // in this same batch supersedes or overwrites (crash-recovery
654
+ // re-append, #1068 stub/supersede pair). No dispatch, no stdout line,
655
+ // no tally for it — the winning occurrence (dispatched below, at its
656
+ // own position) already produces the ONE note this physical session
657
+ // gets. BV-004 ceiling: `runState.total` still counts this raw line,
658
+ // so `created+updated+skipped+failed` no longer partitions `total`
659
+ // for a `--kind session` run that collapsed at least one duplicate —
660
+ // no test pins that invariant for session kind (only for `learning`,
661
+ // where duplicates are not collapsed), and a partially-written vault
662
+ // from a batch that could not be fully deduped is the worse failure
663
+ // mode. Revisit with a dedicated telemetry action if an operator ever
664
+ // needs to name WHICH lines were collapsed, not just how many notes
665
+ // were written.
666
+ continue;
667
+ }
668
+ await dispatchEntry(entry, sessionLineNums[i]);
669
+ }
670
+ }
671
+
587
672
  // ── Run close-out (#1147) ───────────────────────────────────────────────────
588
673
  //
589
674
  // The happy tail. Both roll-ups live in `finishRun` above, which every abort
@@ -101,10 +101,39 @@ The two slots are `parallelPeer.mode` and `parallelPeer.pid`; `<ageHours>` is th
101
101
 
102
102
  ### Outcome handling
103
103
 
104
- - **Worktree anlegen + starten** → invoke `enterWorktree({ basePath, sessionId, branch, repoRoot })` from `scripts/lib/autopilot/worktree-pipeline.mjs`. The helper creates a sibling worktree at `<basePath>/<repo-name>-<sessionId>/`, runs idempotency + boundary checks, and logs a WARN line to stderr on fresh creation. When `<branch>` is already checked out by another worktree — the normal case, since Phase 0.5 passes the current HEAD — the worktree lands on a fresh `so/<sessionId>` branch created at `<branch>` and the helper returns `{ branch: 'so/<sessionId>', promotedFrom: '<branch>' }` (#1067); the new worktree's STATE.md `branch` MUST record `so/<sessionId>` and note `promoted from <branch>@<repoRoot>`. Once the worktree exists, and BEFORE exiting the preamble flow, call `leaveSourceRoot({ repoRoot, sessionId: <the raw physical session_id owning this root's lock/registry entry>, semanticSessionId, reason: 'worktree-promotion' })` from `scripts/lib/session-transition.mjs`. **Read that `sessionId` from this root's `.orchestrator/session.lock` via `readLock({ repoRoot })` never the semantic label, and never `current-session.json`, which may describe a peer session (#863); a wrong id aborts the teardown with `ok: false, reason: 'lock-session-mismatch:<owner>'` and removes nothing.** In detail: the promotion is a PROCESS BOUNDARY, not a live migration (#1069) — the old root is deregistered and its `session.lock` released BEFORE the new worktree's own Phase 1.2 acquires, so the two roots never both own a live claim at once. The call never throws; on `{ ok: false }` emit a stderr WARN `parallel-aware: leaveSourceRoot: <reason>` and continue (the destination worktree already exists — aborting here would leave the two-live-roots state the call prevents). Then exit the current preamble flow — the new worktree's own session-start runs from scratch (Phase 1 onwards). On failure (`WorktreeBoundaryError` or `git worktree add` non-zero exit), emit a stderr warning `parallel-aware: enterWorktree failed: <error>; falling back to Manuell` and proceed via the Manuell path.
104
+ - **Worktree anlegen + starten** → invoke `enterWorktree({ basePath, sessionId, branch, repoRoot, rawSessionId, reason: 'worktree-promotion' })` from `scripts/lib/autopilot/worktree-pipeline.mjs`. The helper creates a sibling worktree at `<basePath>/<repo-name>-<sessionId>/`, runs idempotency + boundary checks, and logs a WARN line to stderr on fresh creation. When `<branch>` is already checked out by another worktree — the normal case, since Phase 0.5 passes the current HEAD — the worktree lands on a fresh `so/<sessionId>` branch created at `<branch>` and the helper returns `{ branch: 'so/<sessionId>', promotedFrom: '<branch>' }` (#1067); the new worktree's STATE.md `branch` MUST record `so/<sessionId>` and note `promoted from <branch>@<repoRoot>`. Since #1170, `enterWorktree` releases the source root ITSELF once the destination worktree provably exists — it calls `leaveSourceRoot({ repoRoot, sessionId: rawSessionId, semanticSessionId: sessionId, reason })` from `scripts/lib/session-transition.mjs` internally, on BOTH success exits, so this AUQ handler makes no separate `leaveSourceRoot` call. `rawSessionId` — **read from this root's `.orchestrator/session.lock` via `readLock({ repoRoot })`, never the semantic label, and never `current-session.json`, which may describe a peer session (#863)** — is the RAW physical `session_id` owning this root's lock/registry entry; a wrong id aborts the teardown with `left.ok: false, reason: 'lock-session-mismatch:<owner>'` and removes nothing. In detail: the promotion is a PROCESS BOUNDARY, not a live migration (#1069) — the old root is deregistered and its `session.lock` released BEFORE the new worktree's own Phase 1.2 acquires, so the two roots never both own a live claim at once. `enterWorktree`'s return value carries the outcome as `left: { ok, steps, reason? }`; `leaveSourceRoot()` never throws, so on `left.ok !== true` `enterWorktree` itself emits the stderr WARN `enterWorktree: leaveSourceRoot: <reason>` and the promotion continues regardless (the destination worktree already exists — aborting here would leave the two-live-roots state the call prevents). Then exit the current preamble flow — the new worktree's own session-start runs from scratch (Phase 1 onwards). On failure (`WorktreeBoundaryError` or `git worktree add` non-zero exit), emit a stderr warning `parallel-aware: enterWorktree failed: <error>; falling back to Manuell` and proceed via the Manuell path.
105
105
  - **Manuell** → append a Deviation via `appendDeviationOnDisk()`:
106
106
  `Worktree-Auto-Promotion declined; running in-place alongside session_id=<peer.sessionId>, mode=<peer.mode>, pid=<peer.pid>. PSA-001/PSA-002/PSA-004 discipline applies.`
107
- Continue Phase-0.
107
+ Continue Phase-0 — and run the **Peer-Scope-Union** protocol below before the first write.
108
+
109
+ ### Peer-Scope-Union (Manuell only, #1195)
110
+
111
+ In-place beside a peer is survivable when the two scopes are DECLARED to each other rather than discovered by collision. Measured 2026-09-02 in a consumer repo: a 4-subagent session ran beside a deep session (wave 4, `enforcement: strict`) in ONE checkout, no worktree, zero collisions — the peer's paths were carried in the deep session's `allowedPaths` union (19 → 41, `--assert-subset` green) across a wave rollover.
112
+
113
+ Four steps, in order. Steps 1 and 2 are the protocol; 3 and 4 are what keeps it honest.
114
+
115
+ 1. **Declare the COMPLETE path list.** The arriving session sends the peer every path it will write — including the ones a script produces (fixtures, snapshots, result files, temp helpers), not only the ones it plans to edit by hand. A path omitted here is a path the peer's guard reports as a violation. In the same message it adopts the peer's resource rules.
116
+ 2. **The peer unions.** The receiving coordinator adds those paths to its `allowedPaths` as ONE record `peer-session-<id>` in the wave's scope manifest, re-asserts subset/disjointness, and re-materializes them on every wave rollover (`skills/wave-executor/wave-loop.md` § Scope Manifest).
117
+ 3. **Probe with ONE real write.** Before dispatching any agent, make one small PLANNED Edit from the declared list. A denial here costs one edit; the same denial found after a fan-out costs the wave.
118
+ 4. **Announce before committing.** The arriving session sends its final file list, the peer sequences its own push behind it, and the SHAs come back. The git index is shared (PSA-007) — sequencing it is the only thing that makes two sessions in one checkout committable.
119
+
120
+ Message template for step 1 (`SendMessage`, first line self-contained per `.claude/rules/cross-session-messaging.md`):
121
+
122
+ ```
123
+ Scope-union request: I will write exactly these paths in <repo> — please add them to your allowedPaths.
124
+
125
+ Paths (complete, incl. files my scripts write):
126
+ - <path>
127
+ - <path>
128
+
129
+ Resource rules I adopt from you: no build, no dev-server ports, no service stop/restart,
130
+ commit only via `git commit --only <my files>` after announcing, no push, no tag.
131
+
132
+ I will probe with ONE planned edit before dispatching, announce my final file list before
133
+ committing, and send you the SHAs afterwards.
134
+ ```
135
+
136
+ Delivery is never guaranteed (CSM-004): an unanswered request establishes nothing. Without a confirmed union, do not write beside the peer — take the worktree instead.
108
137
  - **Abbrechen** → exit Phase-0 immediately. No file writes.
109
138
 
110
139
  ## Always-OK Pass-Through (no AUQ)
@@ -134,14 +134,23 @@ async function runParallelAwarePreamble({ repoRoot, callerMode, callerSessionId
134
134
  return { outcome: 'EXCLUSIVE_BLOCKED', callerClass, blockingSession: exclusiveActive, active: classifiedActive };
135
135
  }
136
136
 
137
+ // GH#67: a registry-sourced peer whose lock is SUPERSEDED (a LIVE lock at
138
+ // this repoRoot is owned by a DIFFERENT raw session_id) is likely a
139
+ // finished-but-still-fresh task on a platform without SessionEnd. It stays
140
+ // visible in `active` (never a filter — the lock is advisory, #1085
141
+ // contract), but it is not eligible to trigger the Promotion AUQ. Split it
142
+ // out as advisory before the parallelPeer lookup.
143
+ const supersededPeers = classifiedActive.filter((e) => e.lockSuperseded === true);
144
+ const promotionEligible = classifiedActive.filter((e) => e.lockSuperseded !== true);
145
+
137
146
  const parallelPeer = callerClass === 'parallel-ok'
138
- ? classifiedActive.find((e) => e._class === 'parallel-ok' && e.sessionId !== callerSessionId)
147
+ ? promotionEligible.find((e) => e._class === 'parallel-ok' && e.sessionId !== callerSessionId)
139
148
  : null;
140
149
  if (parallelPeer) {
141
150
  return { outcome: 'PROMOTION_OFFER', callerClass, parallelPeer, active: classifiedActive };
142
151
  }
143
152
 
144
- return { outcome: 'PASS_THROUGH', callerClass, active: classifiedActive };
153
+ return { outcome: 'PASS_THROUGH', callerClass, active: classifiedActive, advisory: supersededPeers };
145
154
  }
146
155
  ```
147
156
 
@@ -153,7 +162,9 @@ The skill consuming the preamble translates the outcome:
153
162
  |---------|--------|
154
163
  | `PASS_THROUGH` | Continue immediately. No AUQ. Pre-P1.3 behavior. |
155
164
  | `EXCLUSIVE_BLOCKED` | Fire Exclusive-Conflict AUQ from `parallel-aware-auq.md`. Block until user response. On "Abbrechen": exit cleanly. On "Andere Session beenden": surface to user (preamble does NOT kill other session). On "Warten": pause Phase 0; re-run preamble on user retry. |
156
- | `PROMOTION_OFFER` | Fire Promotion AUQ from `parallel-aware-auq.md`. On "Worktree anlegen": call enterWorktree() from worktree-pipeline.mjs, thenBEFORE exiting Phase 0 `leaveSourceRoot({ repoRoot, sessionId, semanticSessionId, reason: 'worktree-promotion' })` from `session-transition.mjs` (see parallel-aware-auq.md outcome-handling). `sessionId` is the RAW physical `session_id` from this root's `.orchestrator/session.lock` (`readLock({ repoRoot })`), never the semantic label and never `current-session.json` (may describe a peer, #863). The promotion is a process boundary, not a live migration (#1069): the old root is deregistered and its lock released BEFORE the new worktree's own Phase 1.2 acquires — never both roots owning at once. It never throws; on `{ ok: false }` emit a stderr WARN `parallel-aware: leaveSourceRoot: <reason>` and continue. On "Manuell": append Deviation (`Worktree-Auto-Promotion declined; running in-place alongside session_id=<peer.sessionId>`) and continue. On "Abbrechen": exit. |
165
+ | `PROMOTION_OFFER` | Fire Promotion AUQ from `parallel-aware-auq.md`. On "Worktree anlegen": call `enterWorktree({ ..., rawSessionId, reason: 'worktree-promotion' })` from worktree-pipeline.mjs (see parallel-aware-auq.md outcome-handling) since #1170 this ONE call also releases the source root: it calls `leaveSourceRoot({ repoRoot, sessionId: rawSessionId, semanticSessionId, reason })` from `session-transition.mjs` internally, on BOTH success exits, so no separate call is made here. `rawSessionId` is the RAW physical `session_id` from this root's `.orchestrator/session.lock` (`readLock({ repoRoot })`), never the semantic label and never `current-session.json` (may describe a peer, #863). The promotion is a process boundary, not a live migration (#1069): the old root is deregistered and its lock released BEFORE the new worktree's own Phase 1.2 acquires — never both roots owning at once. The return value's `left` field carries the outcome; `leaveSourceRoot()` never throws, so on `left.ok !== true` `enterWorktree` itself emits the stderr WARN `enterWorktree: leaveSourceRoot: <reason>` and the promotion continues regardless. On "Manuell": append Deviation (`Worktree-Auto-Promotion declined; running in-place alongside session_id=<peer.sessionId>`) and continue. On "Abbrechen": exit. |
166
+
167
+ **Superseded-lock advisory (GH#67).** A `discovered` peer with `lockSuperseded: true` never fires the Promotion AUQ — it is downgraded to the `advisory` array on the `PASS_THROUGH` result instead (see the `runParallelAwarePreamble` reference above), because a live lock at this repoRoot is owned by a different raw session_id and the entry is likely a finished-but-still-fresh task on a platform without SessionEnd, not a live collision (#1085 advisory-lock contract — the entry is never filtered, only downgraded). The consuming skill prints ONE advisory line per entry: `parallel-aware: registry entry <sessionId> (last heartbeat <N> min ago) is superseded by this root's live lock <lockOwnerId> — likely a finished task on a platform without SessionEnd (GH#67); still counted for PSA-001 awareness`, then continues. `lockSuperseded: false` with `lockOwnerId: null` means "no live lock here" — distinct from "own lock". The same session id remains PSA-002-relevant if it also shows up in STATE.md (`source: 'state-md'`, handled unchanged by Phase 1.2.1/Phase 1b below).
157
168
 
158
169
  ## Phase 1b Peer-Guard (defense-in-depth)
159
170
 
@@ -169,7 +180,9 @@ The guard is a SOFT-GATE — operator can override, but the warning is mandatory
169
180
  findPeers(repoRoot, { mySessionId }) → peer = peers.find((p) => p.source === 'state-md') →
170
181
  peer === null → safe to write STATE.md; continue Phase 1b normally.
171
182
  peer !== null → fire Promotion AUQ (parallel-aware-auq.md "Promotion" block).
172
- On "Worktree anlegen": enterWorktree() leaveSourceRoot(this root)
183
+ On "Worktree anlegen": enterWorktree(..., rawSessionId) releases
184
+ the source root internally (#1170; no
185
+ separate leaveSourceRoot call needed)
173
186
  → continue in sibling (process boundary,
174
187
  old root released before the new acquire).
175
188
  On "Manuell": appendDeviationOnDisk() + continue in-place.
@@ -147,7 +147,7 @@ This is the wave-plan-time analog of PSA-007 (subagents never race the shared gi
147
147
 
148
148
  The rule above deconflicts writers *inside one wave*. The same working copy is also shared across SESSIONS, and `<state-dir>/wave-scope.json` is the one control artefact that constrains writes rather than describing them. It lives in the working copy, not in the session — so before #1123 a manifest written by session A governed session B's every Edit. Measured 2026-08-22 (#1082): a Discovery wave's `allowedPaths: []` — prescribed for every Discovery wave — denied all writes of an unrelated parallel session, with a deny reason that could only tell it to fix a wave plan it does not own.
149
149
 
150
- **The manifest is SESSION-BOUND since #1123.** The coordinator that writes it names itself in two optional fields, `session` (raw `session_id`) and `semantic_session`, both from ONE `sessionAttribution(repoRoot)` call (`scripts/lib/events.mjs`) — see `skills/wave-executor/wave-loop.md` § Scope Manifest 1. `hooks/enforce-scope.mjs` Gate 3b classifies the manifest with `readOwnSessionIds()` + `classifyManifestSession()` (`scripts/lib/session-identity/own-session.mjs`):
150
+ **The manifest is SESSION-BOUND since #1123.** The coordinator that writes it names itself in two optional fields, `session` (raw `session_id`) and `semantic_session`, both from ONE `sessionAttribution(repoRoot)` call (`scripts/lib/events.mjs`) — see `skills/wave-executor/wave-loop.md` § Scope Manifest 1. `hooks/enforce-scope.mjs` Gate 3b classifies the manifest with `readProcessLocalSessionIds()` + `classifyManifestSession()` (`scripts/lib/session-identity/own-session.mjs`) — process-local tiers only (hook payload, `CLAUDE_CODE_SESSION_ID`), never the repo-global `session.lock`, which is shared by every session in the checkout and made a peer's manifest read `own` (#1194):
151
151
 
152
152
  - **`foreign`** (ids present, none of them ours) → the gate ALLOWS the write and emits one `orchestrator.scope.foreign_session_ignored` event. A foreign manifest is somebody else's wave plan; it never had authority here, and the event keeps the skip counted rather than silent.
153
153
  - **`own`** → enforce, unchanged.
@@ -131,7 +131,7 @@ schema, so nobody double-checks the actual runtime behavior, and invalid data pa
131
131
  undetected.
132
132
 
133
133
  Reference case: `maxLength: 63` was the natural way to express S3 bucket-name limits. The
134
- downstream validator (`aiat-poc-infra/scripts/estate/validate-estate-yaml.sh`) is a hand-written
134
+ downstream validator (`aiat-poc-infra/scripts/estate/validate-estate-yaml.sh`) <!-- path-check: historical --> is a hand-written
135
135
  JSON-Schema mini-interpreter that does not implement `maxLength` — it would have parsed the
136
136
  schema, not recognized the keyword, and silently done nothing, so a 200-character bucket name
137
137
  would still validate green. Decision: extend the consumer (option 1) rather than relying only on
@@ -24,7 +24,10 @@ For harness < 2.1.105 (no monitor support), the skill's manual probes documented
24
24
 
25
25
  This skill reads from the project's `## Session Config` section in the platform instruction file:
26
26
 
27
- - **`health-endpoints`** — list of `{name, url}` objects for service health checks
27
+ - **`health-endpoints`** — list of `{name, url}` objects for service health checks. Parsed
28
+ content-scoped by `scripts/lib/config/health-endpoints.mjs` (#1174), which accepts the nested
29
+ block form this wizard writes (below), an inline object array, and a bare list of URLs — see
30
+ `docs/session-config-reference.md` § VCS & Infrastructure for the full form table.
28
31
  - **`cross-repos`** — list of related repositories for critical issue scanning
29
32
 
30
33
  Both fields are optional. The skill degrades gracefully when either is missing. On Codex this means `AGENTS.md`; on Claude/Cursor it means `CLAUDE.md`.
@@ -128,6 +128,11 @@ ecosystem-health:
128
128
  the block is NOT overwritten. The wizard prints "Skipped (already present)" and
129
129
  exits 0. Re-run to edit: remove the existing block first, then re-run.
130
130
 
131
+ This nested `health-endpoints:` block (an indented list under a valueless header) is now parsed
132
+ content-scoped by `scripts/lib/config/health-endpoints.mjs` (#1174) — before that fix the flat
133
+ key/value reader bailed to `null` the moment it saw the `{`/nested-list shape this wizard writes,
134
+ so the block above wrote successfully but the ecosystem-health skill silently never saw it.
135
+
131
136
  ### 4b — `.orchestrator/policy/ecosystem.json`
132
137
 
133
138
  ```json
@@ -34,6 +34,8 @@ Do NOT proceed past Phase 0 if GATE_CLOSED. There is no bypass. Refer to `skills
34
34
 
35
35
  ## Phase 1: Config & Data Loading
36
36
 
37
+ **Telemetry start marker (#1200):** note the current wall-clock time before Step 1.1 runs (e.g. `date +%s%3N`, or the coordinator's own turn-start instant). Every `orchestrator.evolve.completed` emit in Phase 1 / Phase 3 below reports `duration_ms` (placeholder `DURATION_MS`) as the elapsed milliseconds since this marker — same in-memory-value convention as `CT`/`AC`/`ASK`/`DROP` in `skills/session-end/SKILL.md`'s `orchestrator.handover.gated` emits.
38
+
37
39
  ### 1.1 Read Session Config
38
40
 
39
41
  Read and parse Session Config per `skills/_shared/config-reading.md`. Store result as `$CONFIG`.
@@ -44,6 +46,13 @@ Extract `persistence` from `$CONFIG`. If `persistence` is `false`, abort with me
44
46
 
45
47
  > "Learnings require persistence to be enabled in Session Config. Add `persistence: true` to your Session Config block (CLAUDE.md for Claude Code, AGENTS.md for Codex CLI)."
46
48
 
49
+ **Telemetry on abort (#1200):** before stopping, emit the abort form of the run-completion event:
50
+
51
+ ```bash
52
+ node scripts/emit-event.mjs --type orchestrator.evolve.completed --payload \
53
+ "$(node -e "process.stdout.write(JSON.stringify({aborted: 'persistence-disabled', reason: 'Learnings require persistence to be enabled in Session Config.'.slice(0,300), duration_ms: DURATION_MS}))")"
54
+ ```
55
+
47
56
  ### 1.3 Determine Mode
48
57
 
49
58
  Read mode from `$ARGUMENTS`:
@@ -91,7 +100,12 @@ Extract learnings from session history.
91
100
  - Read all entries from `.orchestrator/metrics/sessions.jsonl` (or `<state-dir>/metrics/sessions.jsonl` if the v2 path does not exist — see Phase 1.4 fallback)
92
101
  - Parse each JSONL line as JSON
93
102
  - Sort by `completed_at` descending (most recent first)
94
- - If no sessions found, abort: "No session data available. Complete at least one session before running evolve."
103
+ - If no sessions found, abort: "No session data available. Complete at least one session before running evolve." **Telemetry on abort (#1200):** before stopping, emit:
104
+
105
+ ```bash
106
+ node scripts/emit-event.mjs --type orchestrator.evolve.completed --payload \
107
+ "$(node -e "process.stdout.write(JSON.stringify({aborted: 'no-session-data', reason: 'No session data available. Complete at least one session before running evolve.'.slice(0,300), duration_ms: DURATION_MS}))")"
108
+ ```
95
109
 
96
110
  ### Step 3.1b: Read Extra Sources (#638)
97
111
 
@@ -380,6 +394,13 @@ For confirmed learnings, use atomic rewrite strategy:
380
394
 
381
395
  Report: "Saved N new learnings, updated M existing. Total active: K."
382
396
 
397
+ **Telemetry (#1200):** emit the run-completion event as the last action of this step, using the counts already computed above — `N` (Step 3.5(4) new-learnings count) → `appended`, `M` (Step 3.5(2) reinforced-existing count) → `boosted`, `$PRUNE.archived` (the sweep CLI's returned total, Step 3.5(5)) → `pruned`. `promoted` is always `0` from THIS call site: promotion to `public` scope is the separate `npm run share:hw-learnings -- --promote` CLI, never invoked by `/evolve analyze` itself — see `docs/events-schema.md`. All four counters are ALWAYS present, including as `0`:
398
+
399
+ ```bash
400
+ node scripts/emit-event.mjs --type orchestrator.evolve.completed --payload \
401
+ "$(node -e "process.stdout.write(JSON.stringify({appended: N, boosted: M, pruned: PRUNED, promoted: 0, duration_ms: DURATION_MS}))")"
402
+ ```
403
+
383
404
  ### Step 3.6: C2 Auto-Repair Feeder (opt-in — #647)
384
405
 
385
406
  > **Default OFF (advisory-only).** With no `skill-evolution:` block in Session Config, this step surfaces repair candidates as ADVICE only — it applies nothing and opens no MR. This mirrors the opt-in precedent of `slopcheck` (#520) and `verification-auto-fix` (#521): the engine is dark unless explicitly enabled.
@@ -542,6 +563,8 @@ N active learnings (M high confidence, K expiring soon)
542
563
 
543
564
  Single-pass LLM derivation of USER.md + AGENT.md (peer cards from #503) updates from current learnings + sessions + steering files. Dry-run-default per #506 EARS contract.
544
565
 
566
+ **Telemetry start marker (#1200):** note the current wall-clock time at Phase 6 entry (`DURATION_MS` in the Step 6.4/6.5 emits below is the elapsed milliseconds since this marker) — same placeholder convention as `skills/session-end/SKILL.md`'s `orchestrator.handover.gated` emits.
567
+
545
568
  ### Step 6.0: Argument Parsing
546
569
 
547
570
  Parse `$ARGUMENTS` for trailing flags after the `dialectic` keyword:
@@ -603,6 +626,13 @@ const result = await runDialecticDeriver({
603
626
  - If `--apply`: call `mergePeerCard(existingBody, managedUpdates)` from `scripts/lib/peer-cards/merger.mjs` for each card target, then `writePeerCard(repoRoot, 'user', mergedUserCard)` and `writePeerCard(repoRoot, 'agent', mergedAgentCard)` from `scripts/lib/peer-cards/writer.mjs`. Update the `updated:` frontmatter.
604
627
  - Report: `Dialectic-derived: M deltas to USER.md, N deltas to AGENT.md. Dry-run | Applied. Tokens: in=<X> out=<Y>.`
605
628
 
629
+ **Telemetry (#1200):** immediately after the report line above, emit the success form (`mode` mirrors which branch ran):
630
+
631
+ ```bash
632
+ node scripts/emit-event.mjs --type orchestrator.dialectic.completed --payload \
633
+ "$(node -e "process.stdout.write(JSON.stringify({mode: 'MODE', user_deltas: M, agent_deltas: N, tokens_in: X, tokens_out: Y, duration_ms: DURATION_MS}))")"
634
+ ```
635
+
606
636
  ### Step 6.5: Error Handling
607
637
  - `status: 'unknown-model'` → fail with clear error (already thrown by validateModel)
608
638
  - `status: 'budget-exceeded'` → emit `{status:'budget-exceeded', used:N, budget:M}`, do NOT truncate
@@ -610,6 +640,13 @@ const result = await runDialecticDeriver({
610
640
  - `status: 'empty-input'` → exit clean with message "dialectic: skipped (no input)"
611
641
  - subagent crash → log ⚠, exit cleanly (do NOT write to `.orchestrator/dialectic-pending.md`)
612
642
 
643
+ **Telemetry (#1200):** for EACH outcome above, before exiting, emit `orchestrator.dialectic.completed` in its abort form — `SLUG` is `unknown-model` \| `budget-exceeded` \| `would-empty-card` \| `empty-input` \| `subagent-crash` respectively (the subagent-crash case has no `runDialecticDeriver` status of its own; use the literal slug `subagent-crash`):
644
+
645
+ ```bash
646
+ node scripts/emit-event.mjs --type orchestrator.dialectic.completed --payload \
647
+ "$(node -e "process.stdout.write(JSON.stringify({aborted: 'SLUG', duration_ms: DURATION_MS}))")"
648
+ ```
649
+
613
650
  Cross-reference: PRD #506 AC1-AC4 + EARS gates. Vault Integration: dialectic does NOT mirror to vault (#506 scope — peer cards are repo-local by design; vault mirror is for cross-repo sessions/learnings).
614
651
 
615
652
  ---
@@ -20,11 +20,12 @@ tools: Read, Grep, Glob, Bash, Write, Task
20
20
 
21
21
  ## Purpose
22
22
 
23
- The most expensive defects of the first run (EventDrop `main-2026-08-28-a6`: a core feature with no
24
- entry point anywhere in the UI, burned free quota, a plan gate that could be bypassed, a
25
- claim-vs-code contradiction, EUR copy in a USD context) are **invisible to every code-centric
26
- check**. They are not bugs in a function; they are gaps between five surfaces — marketing copy,
27
- code constants, live UI, outbound mail, real usage data — that no single surface can reveal.
23
+ The most expensive defects of a consumer product's first run (session id redacted) — a core
24
+ feature with no entry point anywhere in the UI, burned free quota, a plan gate that could be
25
+ bypassed, a claim-vs-code contradiction, EUR copy in a USD context are **invisible to every
26
+ code-centric check**. They are not bugs in a function; they are gaps between five surfaces —
27
+ marketing copy, code constants, live UI, outbound mail, real usage data — that no single
28
+ surface can reveal.
28
29
 
29
30
  This skill dispatches seven roles that each own one surface, then makes the coordinator personally
30
31
  re-verify every P0 before it is written down. The re-verification is the value, not the fan-out:
@@ -156,9 +156,9 @@ himself.
156
156
  ### 2.3 Invoke `runReconcile`
157
157
 
158
158
  ```javascript
159
- import { runReconcile } from '$PLUGIN_ROOT/scripts/lib/reconcile/engine.mjs';
159
+ import { runReconcileFromSkill } from '$PLUGIN_ROOT/scripts/lib/reconcile/engine.mjs';
160
160
 
161
- const { proposals, rejected, summary, error } = await runReconcile({
161
+ const { proposals, rejected, summary, error } = await runReconcileFromSkill({
162
162
  repoRoot, // absolute path from git rev-parse --show-toplevel
163
163
  ruleExpiryDays: RULE_EXPIRY_DAYS, // empty → undefined → engine per-type TTL
164
164
  minRuleDays: MIN_RULE_DAYS, // default 7 — floors a near-dead expires-at
@@ -166,6 +166,9 @@ const { proposals, rejected, summary, error } = await runReconcile({
166
166
  maxProposalsPerRun: MAX_PROPOSALS_PER_RUN, // default 10 — volume brake (issue #900 D)
167
167
  now: new Date(),
168
168
  dryRun: DRY_RUN, // true → engine touches no disk (no idempotency sidecar write)
169
+ // trigger is pinned to 'skill' IN CODE by runReconcileFromSkill (#1201 Part A) —
170
+ // this prose block no longer sets it.
171
+ targets, // from resolveEffectiveTargets above; recorded when non-empty, omitted otherwise
169
172
  });
170
173
 
171
174
  // The engine does NOT apply a confidence floor — it proposes every eligible
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: remote-offload
3
+ user-invocable: false
4
+ tags: [reference, remote, offload, wave-executor, resource-gate]
5
+ model: haiku
6
+ model-preference: sonnet
7
+ model-preference-codex: gpt-5.4-mini
8
+ model-preference-cursor: claude-sonnet-4-6
9
+ description: Use when local resource pressure would shrink or coordinator-direct a wave, a wave plan carries heavy build/test/audit roles (test, ui, perf), or the operator says offload, remote host, or auslagern — reference for routing that wave role to a declared SSH-reachable host instead of reducing agent count
10
+ ---
11
+
12
+ # Remote Offload — routing a wave role to a declared host instead of shrinking it
13
+
14
+ Repo-side half of #1160: `remote-hosts:` declares hosts, `wave-resource-gate.mjs` places work on them, `remote-dispatch.mjs` runs it. The host-side `offload` CLI (a separate baseline repo owns its SSOT) is invoked as a subprocess; this skill covers what the repo itself knows about it.
15
+
16
+ ## Quick reference
17
+
18
+ | Task | Command |
19
+ |---|---|
20
+ | Host readiness | `offload doctor -H <alias> --brief` |
21
+ | Gate (typecheck/lint/test) | `offload gate <repo> -H <alias>` |
22
+ | Run a command | `offload run <repo> -H <alias> -- <cmd...>` |
23
+ | Read-only analysis | `offload claude <repo> -H <alias> --model <name> < prompt.txt` |
24
+ | Implementation + patch | `offload claude <repo> -H <alias> --write --patch <path> < prompt.txt` |
25
+ | Remove finished jobs | `offload clean -H <alias> --older-than <hours>` |
26
+
27
+ Exit codes (`offload --help`, measured 2026-09-02): `0` ok · `1` usage/config · `2` host unreachable/not ready · `3` remote command failed · `4` sync failed · `5` timeout · `6` empty diff on a `--write` run · `7` account quota exhausted (429) · `8` write lock held. Same map as `OFFLOAD_EXIT_REASONS` in `scripts/lib/wave-executor/remote-dispatch.mjs`.
28
+
29
+ ## 1. Decision rule — offload vs reduce
30
+
31
+ The gate decides, not the coordinator. `applyOffloadDecision()` in `scripts/lib/wave-resource-gate.mjs` only fires when the resource verdict is already `reduce` or `coordinator-direct`, and only AFTER the HR-004 heavy-repo cap — a capped wave that offloads still respects the cap. It never probes the network; the coordinator supplies a readiness WITNESS:
32
+
33
+ - `opts.remoteReady` — `{ [alias]: boolean }`, built from the SessionStart banner line `Offload <alias>: ready=yes …`, or
34
+ - `opts.probeFn` — an async `(alias) => boolean` fallback, consulted only for aliases `remoteReady` doesn't answer for (backed by `remoteDoctor()`, i.e. `offload doctor -H <alias> --brief` parsed by `parseDoctorLine()`).
35
+
36
+ With neither supplied, no host counts as ready and the wave stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/foreign-dispatch.mjs`: `impl-core`, `security-review`, `migration`, `release`, `secrets`) is never offloaded regardless of readiness.
37
+
38
+ ## 2. What is declared where
39
+
40
+ `remote-hosts:` in Session Config (`docs/session-config-reference.md` § Remote Hosts) declares the hosts, in preference order — the gate takes the FIRST host whose `roles-allowed` accepts the wave role and is witnessed ready:
41
+
42
+ ```yaml
43
+ remote-hosts:
44
+ - alias: <ssh-alias> # required, SAFE slug; reaches argv as `-H <alias>`
45
+ roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
46
+ repo-path: ~/path/on/host # optional; SAFE path; default null
47
+ claude-path: ~/.local/bin/claude # optional; SAFE path; default null
48
+ ```
49
+
50
+ Two enums meet here and must not be conflated: `roles-allowed` holds `agent-mapping` roles (`test`/`ui`/`perf`), not wave roles (`Impl-Core`/`Quality`/…). The translation table is `OFFLOADABLE_WAVE_ROLES` in `wave-resource-gate.mjs` (`quality`→`test`, `test`→`test`, `ui`→`ui`, `perf`→`perf`; a wave role absent from that map stays local by default).
51
+
52
+ An `agent-mapping` entry of the form `<role>: ssh:<alias>` routes that wave role to Claude running ON the declared host instead of shrinking the wave; the alias must already exist under `remote-hosts`, or the config parse throws.
53
+
54
+ ## 3. Three channels of work
55
+
56
+ | Channel | Command | Verdict rule |
57
+ |---|---|---|
58
+ | Gate / arbitrary command | `offload gate` / `offload run` | Exit code decides — use the table above, never the prose in the run's own output |
59
+ | Implementation, patch back | `offload claude --write --patch <file>` via `dispatchRemote()` | Empty patch (exit `6`) is a FAILURE regardless of what the run reports; the coordinator READS the patch, then applies it with its own `git apply` — the remote job never touches the repo the coordinator commits from |
60
+ | Read-only analysis | `offload claude` (no `--write`) | No patch is produced; treat the transcript as advisory input, same skepticism as any reviewer output (`receiving-review.md`) |
61
+
62
+ `dispatchRemote()` (`scripts/lib/wave-executor/remote-dispatch.mjs`) is the wave-executor caller for the second and third channels; it emits `orchestrator.remote_dispatch.completed` once per call (`docs/events-schema.md`) — the only ledger record a remote dispatch produces, since a Bash-spawned `offload` child fires no `SubagentStop` hook. Payload: `host`, `role`, `run_id`, `ok`, `exit_code`, `duration_ms`, `patch_files`, `patch_bytes`, `reason` (present on every refusal and every failure class — absence means success). Deliberately excluded from the payload: prompt text, patch body, `patch_path`.
63
+
64
+ ## 4. Rules
65
+
66
+ - **Supervised, not blind.** Read the gate log or the patch before treating it as a result — completed and correct are not the same claim.
67
+ - **Prompt travels on stdin, never argv** (`offload --help`: "prompts travel by file (mode 600), never argv"; argv is visible to every process on the host).
68
+ - **The patch is READ, then applied by the coordinator** — never inside the offloaded job.
69
+ - **`never_foreign` roles are never offloaded** — checked first in `dispatchRemote()`, before any spawn or side effect.
70
+ - **One `--job` per concurrent run.** A job holds ONE set of run artefacts; two parallel runs sharing a job collided until per-run ids were introduced.
71
+ - **Rate-limit (exit `7`) carries the reset time in the message** — do not retry blind.
72
+ - **Secrets never in output** (SEC-008) — `offload` does not print credentials, and the module deliberately excludes prompt text and patch body from telemetry.
73
+ - **Never "clean up" another checkout on the host.** `offload clean` only removes the offload tool's OWN finished job worktrees, never a host's other active checkouts.
74
+
75
+ ## 5. Host readiness checklist
76
+
77
+ - SSH alias configured with key auth (no password/interactive prompt on connect).
78
+ - `tmux` available on the host (for an interactive `offload session`).
79
+ - Claude authenticated ON the host — never copy OAuth credentials between machines (refresh-token rotation invalidates the source copy); log in fresh with `/login` there instead.
80
+ - Repo cloned on the host with headless git credentials configured (no interactive auth prompt on push/pull).
81
+ - Node version matching this repo's `.nvmrc`.
82
+ - Toolchain parity with the local checkout (same package manager, same lockfile).
83
+
84
+ ## 6. Pitfalls measured in this repo
85
+
86
+ - **The pre-push quality gate used to fire on the sync push.** `.husky/pre-push` (#C10) detects a SCRATCH push — an unconfigured remote URL, e.g. the offload tool's own SSH sync target — and skips the gate for it; publish remotes (`origin`, `github`) stay gated regardless. The offload tool has since been fixed upstream to push with `--no-verify` itself, so this repo-side detection is defense-in-depth, not the primary fix.
87
+ - **The host installer must follow this repo's committed lockfile.** `package-lock.json` is tracked here (npm-canonical — `.claude/rules/development.md` § Package Management); install with `npm ci`, never a different package manager's install command, or the host checkout's `node_modules` layout diverges from CI's.
88
+ - **A linked worktree makes `.git` a file, not a directory.** 13 tracked files used to be flagged as "not in repository" in an unmodified worktree at the same layout, because the file form of `.git` was read as an untracked candidate rather than the repository marker — wave 3 fixed `scripts/lib/validate/check-untracked-test-deps.mjs` to treat a `.git` FILE as the repository marker in a linked worktree; the remote gate then ran 15,829/0.
89
+ - **Keychain-route auth shares the host account's usage window with that account's other interactive sessions**, not a dedicated quota — a token-slot profile (`--via slot`) avoids the sharing where a fixed quota matters.
@@ -246,14 +246,15 @@ After learnings are written (Phase 3.6) and the auto-dream decision is made (Pha
246
246
 
247
247
  1. Read `dialectic.cadence` (default 5), `dialectic.model` (default haiku), `dialectic.budget-tokens` (default 8000) from `$CONFIG`.
248
248
 
249
- 2. Invoke `shouldDispatchAutoDialectic` from `scripts/lib/auto-dialectic.mjs`:
249
+ 2. Invoke `decideAndRecordAutoDialectic` from `scripts/lib/auto-dialectic.mjs`:
250
250
  ```javascript
251
- import { shouldDispatchAutoDialectic } from '${PLUGIN_ROOT}/scripts/lib/auto-dialectic.mjs';
252
- const decision = await shouldDispatchAutoDialectic({
251
+ import { decideAndRecordAutoDialectic } from '${PLUGIN_ROOT}/scripts/lib/auto-dialectic.mjs';
252
+ const decision = await decideAndRecordAutoDialectic({
253
253
  repoRoot: process.cwd(),
254
254
  cadence: config.dialectic?.cadence ?? 5,
255
255
  });
256
256
  ```
257
+ Same return shape as `shouldDispatchAutoDialectic` (`{trigger, reason, signals}`) — `decideAndRecordAutoDialectic` calls it internally and additionally emits the mechanical `orchestrator.dialectic.nudge_decided` telemetry record on all four return paths (#1200 part c), so the nudge decision is observable without depending on this prose actually reaching step 5/7.
257
258
 
258
259
  3. If `decision.trigger === false`: log `auto-dialectic: not triggered (${decision.reason})` and continue. Emit no nudge. Do NOT update `.orchestrator/dialectic-last-run`.
259
260
 
@@ -291,17 +292,19 @@ After the auto-dialectic nudge decision is made (Phase 3.6.7), and when the reco
291
292
 
292
293
  1. Read Session Config: `reconcile.enabled` (default `false`), `reconcile['rule-expiry-days']` (default `null` — falls back to per-type TTL in the engine), `reconcile['confidence-floor']` (default `0.5`), `reconcile['min-rule-days']` (default `7` — floor window (days) applied to a proposed rule's `expires-at` so a near-dead or already-elapsed natural expiry never produces a born-dead rule, issue #741.1), `reconcile['min-insight-chars']` (default `24` — opt-in minimum insight length gating the eligibility placeholder-insight check, issue #741.2), `reconcile['max-proposals-per-run']` (default `10` — volume brake, issue #900 D; the engine sorts eligible learnings by confidence DESC and proposes at most this many per run). If `reconcile.enabled` is not `true`, log `reconcile: disabled (reconcile.enabled=false)` and skip all remaining steps.
293
294
 
294
- 2. Invoke `runReconcile` from `scripts/lib/reconcile/engine.mjs`:
295
+ 2. Invoke `runReconcileAtSessionEnd` from `scripts/lib/reconcile/engine.mjs`:
295
296
 
296
297
  ```javascript
297
- import { runReconcile } from '${PLUGIN_ROOT}/scripts/lib/reconcile/engine.mjs';
298
- const { proposals, rejected, summary, error } = await runReconcile({
298
+ import { runReconcileAtSessionEnd } from '${PLUGIN_ROOT}/scripts/lib/reconcile/engine.mjs';
299
+ const { proposals, rejected, summary, error } = await runReconcileAtSessionEnd({
299
300
  repoRoot: process.cwd(),
300
301
  ruleExpiryDays: config.reconcile['rule-expiry-days'] ?? undefined,
301
302
  minRuleDays: config.reconcile['min-rule-days'] ?? undefined,
302
303
  minInsightChars: config.reconcile['min-insight-chars'] ?? undefined,
303
304
  maxProposalsPerRun: config.reconcile['max-proposals-per-run'] ?? undefined,
304
305
  now: new Date(),
306
+ // trigger is pinned to 'session-end' IN CODE by runReconcileAtSessionEnd
307
+ // (#1201 Part A) — this prose block no longer sets it.
305
308
  });
306
309
  ```
307
310