session-orchestrator 3.23.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
package/scripts/vault-mirror.mjs
CHANGED
|
@@ -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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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,
|
|
518
|
-
: await processSession(entry,
|
|
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:
|
|
534
|
+
line: entryLineNum,
|
|
534
535
|
recordId: entryId,
|
|
535
536
|
skipClass: 'validation',
|
|
536
537
|
reason: err.message,
|
|
537
538
|
dryRun,
|
|
538
539
|
});
|
|
539
|
-
|
|
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 ${
|
|
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:
|
|
573
|
+
line: entryLineNum,
|
|
573
574
|
recordId: entryId,
|
|
574
575
|
skipClass: 'mapper-crash',
|
|
575
576
|
reason: err.message,
|
|
576
577
|
dryRun,
|
|
577
578
|
});
|
|
578
|
-
|
|
579
|
+
return;
|
|
579
580
|
}
|
|
580
581
|
// Unexpected filesystem errors → fatal
|
|
581
|
-
process.stderr.write(`vault-mirror: filesystem error on line ${
|
|
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>`.
|
|
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
|
-
?
|
|
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
|
|
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()
|
|
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 `
|
|
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
|
package/skills/evolve/SKILL.md
CHANGED
|
@@ -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
|
|
24
|
-
entry point anywhere in the UI, burned free quota, a plan gate that could be
|
|
25
|
-
claim-vs-code contradiction, EUR copy in a USD context
|
|
26
|
-
check**. They are not bugs in a function; they are gaps between five surfaces —
|
|
27
|
-
code constants, live UI, outbound mail, real usage data — that no single
|
|
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 {
|
|
159
|
+
import { runReconcileFromSkill } from '$PLUGIN_ROOT/scripts/lib/reconcile/engine.mjs';
|
|
160
160
|
|
|
161
|
-
const { proposals, rejected, summary, error } = await
|
|
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 `
|
|
249
|
+
2. Invoke `decideAndRecordAutoDialectic` from `scripts/lib/auto-dialectic.mjs`:
|
|
250
250
|
```javascript
|
|
251
|
-
import {
|
|
252
|
-
const decision = await
|
|
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 `
|
|
295
|
+
2. Invoke `runReconcileAtSessionEnd` from `scripts/lib/reconcile/engine.mjs`:
|
|
295
296
|
|
|
296
297
|
```javascript
|
|
297
|
-
import {
|
|
298
|
-
const { proposals, rejected, summary, error } = await
|
|
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
|
|