@guyghost/swarm-dao-improvement 0.2.1

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.
@@ -0,0 +1,715 @@
1
+ // Swarm DAO — Improvement Orchestrator series runner and CLI.
2
+ //
3
+ // The series machine in packages/core/src/models/improvement-orchestrator.machine.ts
4
+ // is the only series-state authority. This module is the executor: it journals
5
+ // every orchestrator event, persists the series snapshot under
6
+ // evidence/improvement-series/ (restored by deterministic journal replay), and
7
+ // — via `once` — executes exactly the effect authorized by the current series
8
+ // state:
9
+ // preparing INIT_CYCLE -> CYCLE_INITIALIZED (tool)
10
+ // sampling RUN_WORKERS(sensors) -> WORKERS_HARVESTED (tool) | WORKERS_FAILED
11
+ // sealing SUBMIT_SAMPLES -> SAMPLES_SUBMITTED (tool) | SIGNAL_REJECTED
12
+ // auditing RUN_WORKERS(drift) -> WORKERS_HARVESTED (tool) | WORKERS_FAILED
13
+ // arbitrating SUBMIT_DRIFT -> ARBITRATION_SUBMITTED (tool) | SIGNAL_REJECTED
14
+ // grounding RUN_ANCHOR_COMMANDS -> ANCHORS_SUBMITTED (tool)
15
+ // evaluating SUBMIT_EVALUATE -> EVALUATE_SUBMITTED (tool)
16
+ // observing OBSERVE_CYCLE -> CYCLE_SUCCEEDED | CYCLE_AWAITING_HUMAN |
17
+ // CYCLE_FAILED | CYCLE_BLOCKED |
18
+ // CYCLE_CANCELLED (system)
19
+ // cooldown SCHEDULE_NEXT_CYCLE -> COOLDOWN_ELAPSED (system, injected clock)
20
+ // awaitingHumanCycleDecision OBSERVE_CYCLE -> CYCLE_RESUMED (system)
21
+ //
22
+ // Authority split: the CLI forwards ONLY human events (RETRY_WORKERS,
23
+ // RESTART_SERIES, CANCEL_SERIES); tool and system events are produced
24
+ // exclusively by `once`, so free-form text or an AI can never forge a series
25
+ // transition. Anchor commands come only from the frozen
26
+ // models/improvement-loop.graph.json; a failed anchor is submitted honestly,
27
+ // never retried by the orchestrator.
28
+ import { exec as execCallback } from "node:child_process";
29
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
30
+ import { resolve, sep } from "node:path";
31
+ import { parseArgs, promisify } from "node:util";
32
+ import { arbitratePairedSignals, createOrchestratorActor, isRequiredImprovementAnchor, ORCHESTRATOR_MIN_COOLDOWN_MS, ORCHESTRATOR_TERMINAL_STATES, } from "@guyghost/swarm-dao-core/models/improvement";
33
+ import { createImprovementRunner } from "./runner.js";
34
+ // Re-exported for CLI hosts: init validates the cooldown floor.
35
+ export { ORCHESTRATOR_MIN_COOLDOWN_MS };
36
+ import { AUTO_RECORDED_ANCHORS, loadProjectImprovementConfig } from "./config.js";
37
+ import { extractLastJsonObject, runHerdrWorker } from "./workers.js";
38
+ const execAsync = promisify(execCallback);
39
+ export const DEFAULT_SERIES_EVIDENCE_ROOT = "evidence/improvement-series";
40
+ export const DEFAULT_CYCLE_EVIDENCE_ROOT = "evidence/improvement-cycles";
41
+ const ACTIVE_SERIES_FILE = "active-series.json";
42
+ // The AUTO_RECORDED_ANCHORS set (SAMPLES_SEALED and ARBITRATION anchors) and
43
+ // the per-project anchor config live in ./config.js.
44
+ const PHASE_WORKERS = {
45
+ sampling: ["sensor", "counter-sensor"],
46
+ auditing: ["drift-auditor"],
47
+ };
48
+ // Worker prompts are executor configuration, not model state (see
49
+ // models/improvement-orchestrator.review.md); the machine binds only the
50
+ // worker identities, the output contract, and the retry bound.
51
+ const WORKER_PROMPTS = {
52
+ // Sample values are vocabulary-tolerant by model contract: the frozen
53
+ // negative-outcome set {declined, fell} (models/improvement-loop.md,
54
+ // "Deterministic arbitration policy") owns the counter-veto, so prompt
55
+ // phrasing can drift without disarming it. Prompts keep the canonical
56
+ // words so journal samples stay uniformly worded.
57
+ sensor: (scope) => `You are the sensor worker of a Swarm DAO improvement series for scope '${scope}'. ` +
58
+ `Observe the optimizing metric for this scope in the repository around you, then answer with ONLY a JSON object: ` +
59
+ `{"sample": {"value": "improved|held|declined", "evidence": "<concise observation>"}}. No other text.`,
60
+ "counter-sensor": (scope) => `You are the counter-sensor worker of a Swarm DAO improvement series for scope '${scope}'. ` +
61
+ `Observe the counter-metric (the thing that must not regress while the optimizing metric moves), ` +
62
+ `then answer with ONLY a JSON object: {"sample": {"value": "improved|held|declined", "evidence": "<concise observation>"}}. No other text.`,
63
+ "drift-auditor": (scope) => `You are the drift-auditor worker of a Swarm DAO improvement series for scope '${scope}'. ` +
64
+ `Compare the current implementation behavior against the approved reference for this scope, ` +
65
+ `then answer with ONLY a JSON object: {"driftClass": "none|partial|detached", "evidence": "<concise observation>"}. No other text.`,
66
+ };
67
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
68
+ const validSeriesId = (seriesId) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(seriesId) && !seriesId.includes("..");
69
+ const HUMAN_CHANNEL_EVENTS = new Set(["RETRY_WORKERS", "RESTART_SERIES", "CANCEL_SERIES"]);
70
+ /**
71
+ * The CLI submit channel forwards only human events; tool/system events are
72
+ * produced by `once`. CANCEL_SERIES must already carry a non-empty reason so
73
+ * the CLI fails early instead of surfacing a generic machine rejection later.
74
+ */
75
+ export const isHumanChannelEvent = (event) => {
76
+ if (!isRecord(event) || !HUMAN_CHANNEL_EVENTS.has(event.type) || event.source !== "human")
77
+ return false;
78
+ if (event.type === "CANCEL_SERIES")
79
+ return typeof event.reason === "string" && event.reason.trim().length > 0;
80
+ return true;
81
+ };
82
+ /** Maps a persisted improvement cycle state to exactly one typed observation, or null (poll continues). */
83
+ export const mapCycleStateToObservation = (cycleState, terminalReason) => {
84
+ switch (cycleState) {
85
+ case "succeeded":
86
+ return { type: "CYCLE_SUCCEEDED", source: "system" };
87
+ case "adjusting":
88
+ case "retrying":
89
+ return { type: "CYCLE_AWAITING_HUMAN", source: "system" };
90
+ case "failed":
91
+ return { type: "CYCLE_FAILED", source: "system", reason: terminalReason ?? "improvement cycle failed" };
92
+ case "blocked":
93
+ return { type: "CYCLE_BLOCKED", source: "system", reason: terminalReason ?? "improvement cycle blocked" };
94
+ case "cancelled":
95
+ return { type: "CYCLE_CANCELLED", source: "system", reason: terminalReason ?? "improvement cycle cancelled" };
96
+ default:
97
+ return null;
98
+ }
99
+ };
100
+ /**
101
+ * Resolve the anchor commands for a working directory. `.dao/improvement.json`
102
+ * (explicit, human-owned project config) wins; repositories that ship the
103
+ * frozen improvement graph (swarm-dao itself) fall back to it. A missing
104
+ * anchor configuration is an error with actionable guidance, never a silent
105
+ * no-op: grounding without gates would be forged ground contact.
106
+ */
107
+ export const resolveAnchorCommands = async (workDir) => {
108
+ const project = await loadProjectImprovementConfig(workDir);
109
+ if (project)
110
+ return Object.entries(project.raw.anchorCommands);
111
+ return loadFrozenAnchorCommands(workDir);
112
+ };
113
+ /** Reads the frozen anchor commands (minus the auto-recorded pair) from the improvement graph. */
114
+ export const loadFrozenAnchorCommands = async (root) => {
115
+ const graph = JSON.parse(await readFile(resolve(root, "models/improvement-loop.graph.json"), "utf8"));
116
+ const commands = isRecord(graph) && isRecord(graph.anchorCommands) ? graph.anchorCommands : null;
117
+ const required = isRecord(graph) && Array.isArray(graph.requiredAnchors) ? graph.requiredAnchors : null;
118
+ if (!commands || !required)
119
+ throw new Error("models/improvement-loop.graph.json has no anchorCommands map");
120
+ return Object.entries(commands)
121
+ .filter(([anchor]) => !AUTO_RECORDED_ANCHORS.has(anchor))
122
+ .map(([anchor, command]) => {
123
+ if (!required.includes(anchor) ||
124
+ !isRequiredImprovementAnchor(anchor) ||
125
+ typeof command !== "string" ||
126
+ command.trim().length === 0) {
127
+ throw new Error(`frozen anchor '${anchor}' is not a required improvement anchor command`);
128
+ }
129
+ return [anchor, command];
130
+ });
131
+ };
132
+ const tail = (value, max) => (value.length <= max ? value : `…${value.slice(-max + 1)}`);
133
+ const defaultRunCommand = (cwd) => async (command) => {
134
+ try {
135
+ const { stdout } = await execAsync(command, { cwd, timeout: 600_000 });
136
+ return { ok: true, detail: tail(stdout.trim(), 300) || "exit 0" };
137
+ }
138
+ catch (error) {
139
+ const failure = error;
140
+ const detail = [failure.stderr, failure.stdout, failure.message]
141
+ .filter((part) => part && part.length > 0)
142
+ .join(" ");
143
+ return { ok: false, detail: tail(detail.trim(), 300) || "command failed" };
144
+ }
145
+ };
146
+ const defaultRunWorker = (deps, scope) => async (_phase, worker) => {
147
+ const prompt = WORKER_PROMPTS[worker]?.(scope);
148
+ if (!prompt)
149
+ return { ok: false, error: `no prompt configured for worker '${worker}'` };
150
+ return runHerdrWorker({ workDir: resolve(deps.workDir ?? process.cwd()) }, `orchestrator-${worker}`, prompt);
151
+ };
152
+ const sampleFromAnswer = (answer) => ({
153
+ sample: isRecord(answer) && isRecord(answer.sample) ? answer.sample : answer,
154
+ });
155
+ const evidenceFromAnswer = (answer) => {
156
+ const source = isRecord(answer) && isRecord(answer.sample) ? answer.sample : answer;
157
+ const evidence = isRecord(source) && typeof source.evidence === "string" ? source.evidence.trim() : "";
158
+ return evidence ? [evidence] : [];
159
+ };
160
+ export class OrchestratorRunner {
161
+ #seriesId;
162
+ #evidenceRoot;
163
+ #seriesDirectory;
164
+ #clock;
165
+ #actor;
166
+ #sequence = 0;
167
+ #cooldownEnteredAt = null;
168
+ #tail = Promise.resolve();
169
+ constructor(options, seriesDirectory) {
170
+ this.#seriesId = options.seriesId;
171
+ this.#evidenceRoot = resolve(options.evidenceRoot);
172
+ this.#seriesDirectory = seriesDirectory;
173
+ this.#clock = options.clock ?? (() => new Date().toISOString());
174
+ this.#actor = createOrchestratorActor({ seriesId: options.seriesId });
175
+ }
176
+ static async create(options) {
177
+ if (!validSeriesId(options.seriesId))
178
+ throw new Error("seriesId must be a safe non-empty filesystem identifier");
179
+ const root = resolve(options.evidenceRoot);
180
+ const seriesDirectory = resolve(root, options.seriesId);
181
+ if (!seriesDirectory.startsWith(`${root}${sep}`))
182
+ throw new Error("seriesId resolves outside the evidence root");
183
+ await mkdir(seriesDirectory, { recursive: true });
184
+ const runner = new OrchestratorRunner(options, seriesDirectory);
185
+ await runner.#restoreJournal();
186
+ const persisted = await runner.#readPersistedSnapshot();
187
+ if (String(runner.#actor.getSnapshot().value) === "cooldown" && persisted?.state === "cooldown") {
188
+ runner.#cooldownEnteredAt = persisted.cooldownEnteredAt;
189
+ }
190
+ await runner.#persistSnapshot(runner.#serialize());
191
+ return runner;
192
+ }
193
+ snapshot() {
194
+ return this.#serialize();
195
+ }
196
+ submit(input) {
197
+ const operation = this.#tail.then(() => this.#submitNow(input));
198
+ this.#tail = operation.then(() => undefined, () => undefined);
199
+ return operation;
200
+ }
201
+ /**
202
+ * Execute the single effect authorized by the current series state and
203
+ * submit the resulting tool/system event. Human-gated and terminal states
204
+ * never execute an effect.
205
+ */
206
+ async once(deps = {}) {
207
+ const before = this.#serialize();
208
+ const base = { seriesId: this.#seriesId, stateBefore: before.state };
209
+ switch (before.state) {
210
+ case "preparing":
211
+ return this.#initCycle(base, deps);
212
+ case "sampling":
213
+ return this.#runPhaseWorkers("sampling", base, deps);
214
+ case "sealing":
215
+ return this.#submitSamples(base, deps);
216
+ case "auditing":
217
+ return this.#runPhaseWorkers("auditing", base, deps);
218
+ case "arbitrating":
219
+ return this.#submitDrift(base, deps);
220
+ case "grounding":
221
+ return this.#runAnchorCommands(base, deps);
222
+ case "evaluating":
223
+ return this.#submitEvaluate(base, deps);
224
+ case "observing":
225
+ return this.#observeCycle(base, deps);
226
+ case "cooldown":
227
+ return this.#pollCooldown(base, deps);
228
+ case "awaitingHumanCycleDecision":
229
+ return this.#pollResume(base, deps);
230
+ case "workerFailed":
231
+ return {
232
+ ...base,
233
+ stateAfter: before.state,
234
+ executed: false,
235
+ event: null,
236
+ accepted: true,
237
+ issues: [],
238
+ detail: `worker failure pending human RETRY_WORKERS: ${before.context.pendingReason ?? "unknown"}`,
239
+ };
240
+ case "halted":
241
+ return {
242
+ ...base,
243
+ stateAfter: before.state,
244
+ executed: false,
245
+ event: null,
246
+ accepted: true,
247
+ issues: [],
248
+ detail: `series halted (${before.context.pendingReason ?? "unknown"}); human RESTART_SERIES or CANCEL_SERIES required`,
249
+ };
250
+ default:
251
+ return {
252
+ ...base,
253
+ stateAfter: before.state,
254
+ executed: false,
255
+ event: null,
256
+ accepted: true,
257
+ issues: [],
258
+ detail: `series is terminal (${before.state})`,
259
+ };
260
+ }
261
+ }
262
+ #serialize() {
263
+ const snapshot = this.#actor.getSnapshot();
264
+ return {
265
+ seriesId: snapshot.context.seriesId,
266
+ state: String(snapshot.value),
267
+ status: snapshot.status,
268
+ context: structuredClone(snapshot.context),
269
+ cooldownEnteredAt: this.#cooldownEnteredAt,
270
+ };
271
+ }
272
+ async #submitNow(input) {
273
+ const before = this.#serialize();
274
+ let accepted = false;
275
+ let issues = [];
276
+ let event;
277
+ const type = isRecord(input) && typeof input.type === "string" ? input.type : null;
278
+ const source = isRecord(input) && typeof input.source === "string" ? input.source : null;
279
+ if (!type || !source || !(source === "tool" || source === "human" || source === "system")) {
280
+ issues = ["event must be an object with a type and a tool|human|system source"];
281
+ }
282
+ else {
283
+ event = input;
284
+ this.#actor.send(event);
285
+ const candidate = this.#serialize();
286
+ accepted =
287
+ candidate.state !== before.state || JSON.stringify(candidate.context) !== JSON.stringify(before.context);
288
+ if (!accepted)
289
+ issues = ["machine rejected event for the current state or guards"];
290
+ }
291
+ const after = this.#serialize();
292
+ if (accepted && after.state === "cooldown")
293
+ this.#cooldownEnteredAt = this.#clock();
294
+ else if (after.state !== "cooldown")
295
+ this.#cooldownEnteredAt = null;
296
+ // Re-serialize after stamping the cooldown timer: the persisted snapshot
297
+ // must carry cooldownEnteredAt, otherwise every fresh CLI runner restarts
298
+ // the timer on its first poll (dogfood-002 finding).
299
+ const persisted = this.#serialize();
300
+ const entry = {
301
+ sequence: ++this.#sequence,
302
+ seriesId: this.#seriesId,
303
+ receivedAt: this.#clock(),
304
+ eventType: type,
305
+ source,
306
+ accepted,
307
+ issues,
308
+ beforeState: before.state,
309
+ afterState: after.state,
310
+ ...(event ? { event } : {}),
311
+ };
312
+ await appendFile(resolve(this.#seriesDirectory, "journal.ndjson"), `${JSON.stringify(entry)}\n`, "utf8");
313
+ await this.#persistSnapshot(persisted);
314
+ // One active series per scope (invariant 7): the runner maintains the
315
+ // scope registry so every start path (CLI or library) is covered.
316
+ if (accepted && type === "START_SERIES" && typeof after.context.scope === "string") {
317
+ await rememberActiveSeries(this.#evidenceRoot, after.context.scope, this.#seriesId);
318
+ }
319
+ return { accepted, issues, snapshot: persisted };
320
+ }
321
+ async #restoreJournal() {
322
+ let content;
323
+ try {
324
+ content = await readFile(resolve(this.#seriesDirectory, "journal.ndjson"), "utf8");
325
+ }
326
+ catch (error) {
327
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
328
+ return;
329
+ throw error;
330
+ }
331
+ const lines = content.split("\n").filter((line) => line.trim().length > 0);
332
+ for (const [index, line] of lines.entries()) {
333
+ let entry;
334
+ try {
335
+ entry = JSON.parse(line);
336
+ }
337
+ catch {
338
+ throw new Error(`series journal line ${index + 1} is not valid JSON`);
339
+ }
340
+ if (!isRecord(entry) ||
341
+ entry.sequence !== index + 1 ||
342
+ typeof entry.accepted !== "boolean" ||
343
+ entry.seriesId !== this.#seriesId) {
344
+ throw new Error(`series journal line ${index + 1} violates the sequence contract`);
345
+ }
346
+ this.#sequence = entry.sequence;
347
+ if (!entry.accepted)
348
+ continue;
349
+ if (!isRecord(entry.event) || typeof entry.event.type !== "string") {
350
+ throw new Error(`accepted series journal line ${index + 1} has no event`);
351
+ }
352
+ const before = this.#serialize();
353
+ this.#actor.send(entry.event);
354
+ const after = this.#serialize();
355
+ if (after.state !== before.state || JSON.stringify(after.context) !== JSON.stringify(before.context))
356
+ continue;
357
+ throw new Error(`accepted series journal line ${index + 1} cannot be replayed deterministically`);
358
+ }
359
+ }
360
+ async #readPersistedSnapshot() {
361
+ try {
362
+ const parsed = JSON.parse(await readFile(resolve(this.#seriesDirectory, "snapshot.json"), "utf8"));
363
+ return isRecord(parsed) ? parsed : null;
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ async #persistSnapshot(snapshot) {
370
+ await writeFile(resolve(this.#seriesDirectory, "snapshot.json"), `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
371
+ }
372
+ #result(base, submitted, event, detail) {
373
+ return {
374
+ ...base,
375
+ stateAfter: submitted.snapshot.state,
376
+ executed: true,
377
+ event,
378
+ accepted: submitted.accepted,
379
+ issues: submitted.issues,
380
+ detail,
381
+ };
382
+ }
383
+ #gateResult(base, detail) {
384
+ return { ...base, stateAfter: base.stateBefore, executed: false, event: null, accepted: true, issues: [], detail };
385
+ }
386
+ async #cycleRunner(cycleEvidenceRoot) {
387
+ const context = this.#actor.getSnapshot().context;
388
+ if (!context.started || !context.scope || !context.referenceHash || !context.improvementCycleId) {
389
+ throw new Error("series has no active improvement cycle");
390
+ }
391
+ return createImprovementRunner({
392
+ evidenceRoot: cycleEvidenceRoot,
393
+ cycleId: context.improvementCycleId,
394
+ scope: context.scope,
395
+ referenceHash: context.referenceHash,
396
+ });
397
+ }
398
+ #workDirectory() {
399
+ const sequence = this.#actor.getSnapshot().context.cycleSequence;
400
+ return resolve(this.#seriesDirectory, "work", `c${sequence}`);
401
+ }
402
+ async #readWorkAnswer(worker) {
403
+ return JSON.parse(await readFile(resolve(this.#workDirectory(), `worker-${worker}.json`), "utf8"));
404
+ }
405
+ async #initCycle(base, deps) {
406
+ const context = this.#actor.getSnapshot().context;
407
+ if (!context.scope || !context.referenceHash)
408
+ throw new Error("series identity is incomplete");
409
+ const cycleId = `${this.#seriesId}-c${context.cycleSequence + 1}`;
410
+ await createImprovementRunner({
411
+ evidenceRoot: resolve(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT),
412
+ cycleId,
413
+ scope: context.scope,
414
+ referenceHash: context.referenceHash,
415
+ });
416
+ const submitted = await this.submit({ type: "CYCLE_INITIALIZED", source: "tool", cycleId });
417
+ return this.#result(base, submitted, "CYCLE_INITIALIZED", `initialized improvement cycle ${cycleId}`);
418
+ }
419
+ async #runPhaseWorkers(phase, base, deps) {
420
+ const context = this.#actor.getSnapshot().context;
421
+ const runWorker = deps.runWorker ?? defaultRunWorker(deps, context.scope ?? "");
422
+ await mkdir(this.#workDirectory(), { recursive: true });
423
+ for (const worker of PHASE_WORKERS[phase]) {
424
+ const harvest = await runWorker(phase, worker);
425
+ if (!harvest.ok) {
426
+ const submitted = await this.submit({ type: "WORKERS_FAILED", source: "tool", reason: harvest.error, phase });
427
+ return this.#result(base, submitted, "WORKERS_FAILED", harvest.error);
428
+ }
429
+ const answer = extractLastJsonObject(harvest.content);
430
+ if (!answer) {
431
+ // Preserve the harvested transcript so parse failures are diagnosable
432
+ // without replaying the worker by hand (dogfood-002 finding).
433
+ await writeFile(resolve(this.#workDirectory(), `worker-${worker}.transcript.txt`), harvest.content, "utf8").catch(() => undefined);
434
+ const reason = `worker ${worker} produced no parseable JSON answer`;
435
+ const submitted = await this.submit({ type: "WORKERS_FAILED", source: "tool", reason, phase });
436
+ return this.#result(base, submitted, "WORKERS_FAILED", reason);
437
+ }
438
+ await writeFile(resolve(this.#workDirectory(), `worker-${worker}.json`), `${JSON.stringify(answer, null, 2)}\n`, "utf8");
439
+ }
440
+ const submitted = await this.submit({ type: "WORKERS_HARVESTED", source: "tool" });
441
+ return this.#result(base, submitted, "WORKERS_HARVESTED", `harvested ${PHASE_WORKERS[phase].join(", ")}`);
442
+ }
443
+ async #submitSignal(runner, signal) {
444
+ const result = await runner.submit({ occurredAt: this.#clock(), ...signal });
445
+ if (result.accepted)
446
+ return null;
447
+ return this.submit({ type: "SIGNAL_REJECTED", source: "tool", issues: result.issues });
448
+ }
449
+ async #submitSamples(base, deps) {
450
+ const context = this.#actor.getSnapshot().context;
451
+ const runner = await this.#cycleRunner(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT);
452
+ const cycleId = context.improvementCycleId;
453
+ const sensor = await this.#readWorkAnswer("sensor");
454
+ const counter = await this.#readWorkAnswer("counter-sensor");
455
+ const signals = [
456
+ {
457
+ cycleId,
458
+ type: "METRIC_SAMPLED",
459
+ source: "ai",
460
+ producer: "sensor",
461
+ payload: sampleFromAnswer(sensor),
462
+ evidence: evidenceFromAnswer(sensor),
463
+ },
464
+ {
465
+ cycleId,
466
+ type: "COUNTER_SAMPLED",
467
+ source: "ai",
468
+ producer: "counter-sensor",
469
+ payload: sampleFromAnswer(counter),
470
+ evidence: evidenceFromAnswer(counter),
471
+ },
472
+ {
473
+ cycleId,
474
+ type: "SAMPLES_SEALED",
475
+ source: "tool",
476
+ producer: "sample-gate",
477
+ payload: {},
478
+ evidence: ["sensor and counter-sensor samples sealed"],
479
+ },
480
+ ];
481
+ for (const signal of signals) {
482
+ const rejected = await this.#submitSignal(runner, signal);
483
+ if (rejected)
484
+ return this.#result(base, rejected, "SIGNAL_REJECTED", rejected.issues.join("; "));
485
+ }
486
+ const submitted = await this.submit({ type: "SAMPLES_SUBMITTED", source: "tool" });
487
+ return this.#result(base, submitted, "SAMPLES_SUBMITTED", "paired samples sealed into the cycle");
488
+ }
489
+ async #submitDrift(base, deps) {
490
+ const context = this.#actor.getSnapshot().context;
491
+ const runner = await this.#cycleRunner(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT);
492
+ const cycleId = context.improvementCycleId;
493
+ const drift = await this.#readWorkAnswer("drift-auditor");
494
+ const rejectedDrift = await this.#submitSignal(runner, {
495
+ cycleId,
496
+ type: "DRIFT_ESTIMATE",
497
+ source: "ai",
498
+ producer: "drift-auditor",
499
+ payload: { driftClass: isRecord(drift) ? drift.driftClass : undefined },
500
+ evidence: evidenceFromAnswer(drift),
501
+ });
502
+ if (rejectedDrift)
503
+ return this.#result(base, rejectedDrift, "SIGNAL_REJECTED", rejectedDrift.issues.join("; "));
504
+ const cycleSnapshot = runner.snapshot();
505
+ const { outcome } = arbitratePairedSignals(cycleSnapshot.context.metric, cycleSnapshot.context.counterMetric);
506
+ const rejectedArbitration = await this.#submitSignal(runner, {
507
+ cycleId,
508
+ type: "ARBITRATION",
509
+ source: "tool",
510
+ producer: "arbitrator",
511
+ payload: { outcome },
512
+ evidence: [`deterministic arbitration outcome: ${outcome}`],
513
+ });
514
+ if (rejectedArbitration) {
515
+ return this.#result(base, rejectedArbitration, "SIGNAL_REJECTED", rejectedArbitration.issues.join("; "));
516
+ }
517
+ const submitted = await this.submit({ type: "ARBITRATION_SUBMITTED", source: "tool" });
518
+ return this.#result(base, submitted, "ARBITRATION_SUBMITTED", `arbitration outcome: ${outcome}`);
519
+ }
520
+ async #runAnchorCommands(base, deps) {
521
+ const workDir = resolve(deps.workDir ?? process.cwd());
522
+ const runner = await this.#cycleRunner(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT);
523
+ const context = this.#actor.getSnapshot().context;
524
+ const cycleId = context.improvementCycleId;
525
+ const commands = await resolveAnchorCommands(workDir);
526
+ const runCommand = deps.runCommand ?? defaultRunCommand(workDir);
527
+ const outcomes = [];
528
+ for (const [anchor, command] of commands) {
529
+ const outcome = await runCommand(command);
530
+ const result = await runner.submit({
531
+ cycleId,
532
+ type: "ANCHOR_RECORDED",
533
+ source: "tool",
534
+ producer: "anchor-verifier",
535
+ occurredAt: this.#clock(),
536
+ payload: { anchor, status: outcome.ok ? "passed" : "failed" },
537
+ evidence: [`$ ${command}`, outcome.detail],
538
+ });
539
+ if (!result.accepted) {
540
+ throw new Error(`anchor ${anchor} outcome rejected by the cycle runner: ${result.issues.join("; ")}`);
541
+ }
542
+ outcomes.push(`${anchor}: ${outcome.ok ? "passed" : "failed"}`);
543
+ }
544
+ const submitted = await this.submit({ type: "ANCHORS_SUBMITTED", source: "tool" });
545
+ return this.#result(base, submitted, "ANCHORS_SUBMITTED", outcomes.join(", "));
546
+ }
547
+ async #submitEvaluate(base, deps) {
548
+ const context = this.#actor.getSnapshot().context;
549
+ const runner = await this.#cycleRunner(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT);
550
+ const rejected = await this.#submitSignal(runner, {
551
+ cycleId: context.improvementCycleId,
552
+ type: "EVALUATE",
553
+ source: "system",
554
+ producer: "improvement-runner",
555
+ payload: {},
556
+ evidence: [],
557
+ });
558
+ if (rejected)
559
+ return this.#result(base, rejected, "SIGNAL_REJECTED", rejected.issues.join("; "));
560
+ const submitted = await this.submit({ type: "EVALUATE_SUBMITTED", source: "tool" });
561
+ return this.#result(base, submitted, "EVALUATE_SUBMITTED", "evaluation submitted to the cycle");
562
+ }
563
+ async #readCycleSnapshot(deps) {
564
+ const cycleId = this.#actor.getSnapshot().context.improvementCycleId;
565
+ if (!cycleId)
566
+ throw new Error("series has no active improvement cycle to observe");
567
+ const parsed = JSON.parse(await readFile(resolve(deps.cycleEvidenceRoot ?? DEFAULT_CYCLE_EVIDENCE_ROOT, cycleId, "snapshot.json"), "utf8"));
568
+ if (!isRecord(parsed) || typeof parsed.state !== "string") {
569
+ throw new Error(`cycle snapshot for ${cycleId} is malformed`);
570
+ }
571
+ const context = isRecord(parsed.context) ? parsed.context : {};
572
+ return {
573
+ state: parsed.state,
574
+ terminalReason: typeof context.terminalReason === "string" ? context.terminalReason : null,
575
+ };
576
+ }
577
+ async #observeCycle(base, deps) {
578
+ const cycle = await this.#readCycleSnapshot(deps);
579
+ const observation = mapCycleStateToObservation(cycle.state, cycle.terminalReason);
580
+ if (!observation)
581
+ return this.#gateResult(base, `cycle still '${cycle.state}'; poll again`);
582
+ const submitted = await this.submit(observation);
583
+ return this.#result(base, submitted, observation.type, `observed cycle state '${cycle.state}'`);
584
+ }
585
+ async #pollCooldown(base, deps) {
586
+ const snapshot = this.#serialize();
587
+ if (snapshot.cooldownEnteredAt === null || Number.isNaN(Date.parse(snapshot.cooldownEnteredAt))) {
588
+ this.#cooldownEnteredAt = this.#clock();
589
+ await this.#persistSnapshot(this.#serialize());
590
+ return this.#gateResult(base, "cooldown timer started");
591
+ }
592
+ const elapsed = (deps.nowMs ?? Date.now)() - Date.parse(snapshot.cooldownEnteredAt);
593
+ const cooldownMs = snapshot.context.cooldownMs ?? 0;
594
+ if (elapsed < cooldownMs) {
595
+ return this.#gateResult(base, `cooldown pending; ${Math.ceil((cooldownMs - elapsed) / 1000)}s remaining`);
596
+ }
597
+ const submitted = await this.submit({ type: "COOLDOWN_ELAPSED", source: "system" });
598
+ return this.#result(base, submitted, "COOLDOWN_ELAPSED", "cooldown elapsed; next cycle scheduled");
599
+ }
600
+ async #pollResume(base, deps) {
601
+ const cycle = await this.#readCycleSnapshot(deps);
602
+ if (cycle.state !== "sampling") {
603
+ return this.#gateResult(base, `cycle in '${cycle.state}'; human decision still pending`);
604
+ }
605
+ const submitted = await this.submit({ type: "CYCLE_RESUMED", source: "system" });
606
+ return this.#result(base, submitted, "CYCLE_RESUMED", "human resolved the cycle gate; workers rerun");
607
+ }
608
+ }
609
+ const readActiveSeriesMap = async (evidenceRoot) => {
610
+ try {
611
+ const parsed = JSON.parse(await readFile(resolve(evidenceRoot, ACTIVE_SERIES_FILE), "utf8"));
612
+ return isRecord(parsed) ? parsed : {};
613
+ }
614
+ catch {
615
+ return {};
616
+ }
617
+ };
618
+ /** Rejects starting a series for a scope that already has a non-terminal series (invariant 7). */
619
+ export const assertNoActiveSeriesForScope = async (evidenceRoot, scope, seriesId) => {
620
+ const map = await readActiveSeriesMap(evidenceRoot);
621
+ const existing = map[scope];
622
+ if (!existing || existing === seriesId)
623
+ return;
624
+ const other = await OrchestratorRunner.create({ seriesId: existing, evidenceRoot });
625
+ if (!ORCHESTRATOR_TERMINAL_STATES.includes(other.snapshot().state)) {
626
+ throw new Error(`scope '${scope}' already has an active series '${existing}'; cancel it first`);
627
+ }
628
+ };
629
+ const rememberActiveSeries = async (evidenceRoot, scope, seriesId) => {
630
+ const map = await readActiveSeriesMap(evidenceRoot);
631
+ map[scope] = seriesId;
632
+ await writeFile(resolve(evidenceRoot, ACTIVE_SERIES_FILE), `${JSON.stringify(map, null, 2)}\n`, "utf8");
633
+ };
634
+ // ---------------------------------------------------------------------------
635
+ // CLI
636
+ // ---------------------------------------------------------------------------
637
+ const usage = `Usage:
638
+ bun run improvement:series:init -- --series-id <id> --scope <s> --reference-hash <hash> --cooldown-ms <ms>
639
+ bun run improvement:series:status -- --series-id <id> [--evidence-root <path>]
640
+ bun run improvement:series:submit -- --series-id <id> --event <file> [--evidence-root <path>]
641
+ bun run improvement:series:once -- --series-id <id> [--work-dir <dir>] [--evidence-root <path>]`;
642
+ export const runSeriesCliInner = async (argv) => {
643
+ const command = argv[0];
644
+ if (command !== "init" && command !== "status" && command !== "submit" && command !== "once")
645
+ throw new Error(usage);
646
+ const { values } = parseArgs({
647
+ args: argv.slice(1),
648
+ strict: true,
649
+ options: {
650
+ "series-id": { type: "string" },
651
+ scope: { type: "string" },
652
+ "reference-hash": { type: "string" },
653
+ "cooldown-ms": { type: "string" },
654
+ "evidence-root": { type: "string" },
655
+ event: { type: "string" },
656
+ "work-dir": { type: "string" },
657
+ },
658
+ });
659
+ const seriesId = values["series-id"];
660
+ if (!seriesId)
661
+ throw new Error(`--series-id is required\n${usage}`);
662
+ const evidenceRoot = resolve(values["evidence-root"] ?? DEFAULT_SERIES_EVIDENCE_ROOT);
663
+ if (command === "init") {
664
+ const scope = values.scope;
665
+ const referenceHash = values["reference-hash"];
666
+ if (!scope)
667
+ throw new Error(`--scope is required\n${usage}`);
668
+ if (!referenceHash)
669
+ throw new Error(`--reference-hash is required\n${usage}`);
670
+ const cooldownMs = Number(values["cooldown-ms"]);
671
+ if (!Number.isInteger(cooldownMs) || cooldownMs < ORCHESTRATOR_MIN_COOLDOWN_MS) {
672
+ throw new Error(`--cooldown-ms must be an integer >= ${ORCHESTRATOR_MIN_COOLDOWN_MS}\n${usage}`);
673
+ }
674
+ await assertNoActiveSeriesForScope(evidenceRoot, scope, seriesId);
675
+ const runner = await OrchestratorRunner.create({ seriesId, evidenceRoot });
676
+ const result = await runner.submit({ type: "START_SERIES", source: "human", scope, referenceHash, cooldownMs });
677
+ process.stdout.write(`${JSON.stringify(result.snapshot, null, 2)}\n`);
678
+ return result.accepted ? 0 : 2;
679
+ }
680
+ if (command === "submit") {
681
+ if (!values.event)
682
+ throw new Error(`--event is required\n${usage}`);
683
+ const event = JSON.parse(await readFile(resolve(values.event), "utf8"));
684
+ if (!isHumanChannelEvent(event)) {
685
+ throw new Error("CLI submit only forwards human events (RETRY_WORKERS, RESTART_SERIES, CANCEL_SERIES with a non-empty reason)");
686
+ }
687
+ const runner = await OrchestratorRunner.create({ seriesId, evidenceRoot });
688
+ const result = await runner.submit(event);
689
+ process.stdout.write(`${JSON.stringify(result.snapshot, null, 2)}\n`);
690
+ return result.accepted ? 0 : 2;
691
+ }
692
+ if (command === "once") {
693
+ const runner = await OrchestratorRunner.create({ seriesId, evidenceRoot });
694
+ const result = await runner.once({ workDir: values["work-dir"] });
695
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
696
+ return result.event && !result.accepted ? 2 : 0;
697
+ }
698
+ const runner = await OrchestratorRunner.create({ seriesId, evidenceRoot });
699
+ process.stdout.write(`${JSON.stringify(runner.snapshot(), null, 2)}\n`);
700
+ return 0;
701
+ };
702
+ /**
703
+ * Entry point for the `improvement:series:*` scripts and the swarm-dao CLI.
704
+ * Exit codes: 0 success, 2 machine rejection, 1 usage or execution error.
705
+ */
706
+ export const runSeriesCli = async (argv) => {
707
+ try {
708
+ return await runSeriesCliInner(argv);
709
+ }
710
+ catch (error) {
711
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
712
+ return 1;
713
+ }
714
+ };
715
+ //# sourceMappingURL=orchestrator.js.map