@mikenguyen69/harness 0.1.0-beta.3 → 0.1.0-beta.4

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/dist/help.js CHANGED
@@ -16,6 +16,7 @@ Advanced — verification and gates:
16
16
  Advanced — specs and units:
17
17
  harness spec check lint specs, dep DAG, consumes/produces
18
18
  harness spec materialize write idempotent unit.planned facts
19
+ harness spec project emit unit YAML from OpenSpec + projection
19
20
  harness next list units ready to claim
20
21
  harness claim atomically take a ready unit
21
22
  harness heartbeat keep a claim alive
@@ -74,7 +75,8 @@ export const COMMAND_HELP = {
74
75
  doctor: `harness doctor read-only preflight before a run
75
76
  --repo <dir> --routes <routes.toml> [--spool <dir>] [--json]`,
76
77
  run: `harness run drive the spec DAG with an agent fleet
77
- --repo <dir> --config <orchestration.json> --routes <routes.toml> [--spool <dir>] [--report <html>]`,
78
+ --repo <dir> --config <orchestration.json> --routes <routes.toml> [--only <id-prefix>] [--spool <dir>] [--report <html>]
79
+ --only <id-prefix> limits which units the run may claim (same as harness next --only)`,
78
80
  resume: `harness resume continue a run after a signed door approval
79
81
  --unit <id> [--repo <dir>] [--routes <routes.toml>]`,
80
82
  status: `harness status read-only unit lanes from the ledger, with a run-status spool overlay
@@ -1,4 +1,5 @@
1
1
  const DEFAULT_HIGH_TURN_COUNT = 50;
2
+ const DEFAULT_EXPLORE_BEFORE_EDIT = 15;
2
3
  const IMPLEMENT_STEPS = new Set(["implement"]);
3
4
  const REVIEW_STEPS = new Set(["review"]);
4
5
  export function parseActivityLog(text) {
@@ -157,17 +158,16 @@ export function findRetryWithoutDelta(unitEvents) {
157
158
  }
158
159
  return findings;
159
160
  }
160
- export function findExploreBeforeEdit(unitEvents) {
161
+ export function findExploreBeforeEdit(unitEvents, threshold = DEFAULT_EXPLORE_BEFORE_EDIT) {
161
162
  const findings = [];
162
163
  let sinceEditCount = 0;
163
164
  const seen = [];
164
- const EXPLORE_THRESHOLD = 15;
165
165
  for (const e of unitEvents) {
166
166
  if (e.kind !== "tool")
167
167
  continue;
168
168
  const isEdit = e.tool === "write" || e.tool === "edit";
169
169
  if (isEdit) {
170
- if (sinceEditCount > EXPLORE_THRESHOLD) {
170
+ if (sinceEditCount > threshold) {
171
171
  findings.push({
172
172
  id: "explore-before-edit",
173
173
  severity: "info",
@@ -206,6 +206,7 @@ export function analyzeRun(events, opts) {
206
206
  const runId = events.find((e) => e.t === "run.started")
207
207
  ?.run_id ?? "";
208
208
  const highTurnCount = opts.thresholds?.highTurnCount ?? DEFAULT_HIGH_TURN_COUNT;
209
+ const exploreBeforeEdit = opts.thresholds?.exploreBeforeEdit ?? DEFAULT_EXPLORE_BEFORE_EDIT;
209
210
  const stepsByUnit = new Map();
210
211
  const activityByUnit = new Map();
211
212
  for (const e of events) {
@@ -227,7 +228,7 @@ export function analyzeRun(events, opts) {
227
228
  const steps = stepsByUnit.get(unitId) ?? [];
228
229
  const activity = activityByUnit.get(unitId) ?? [];
229
230
  units.push(unitMetrics(unitId, steps, activity, opts.costByUnit));
230
- findings.push(...findRepeatedRead(activity), ...findToolFailureCluster(activity), ...findHighTurnCount(activity, highTurnCount), ...findRetryWithoutDelta(activity), ...findExploreBeforeEdit(activity), ...findVerifyHeavierThanImplement(activity));
231
+ findings.push(...findRepeatedRead(activity), ...findToolFailureCluster(activity), ...findHighTurnCount(activity, highTurnCount), ...findRetryWithoutDelta(activity), ...findExploreBeforeEdit(activity, exploreBeforeEdit), ...findVerifyHeavierThanImplement(activity));
231
232
  }
232
233
  return {
233
234
  runId,
@@ -14,7 +14,7 @@ import { FakeHarness } from "./harness/index.js";
14
14
  import { FakeRouteSource } from "./routes/index.js";
15
15
  import { FakeRunner } from "./runner/index.js";
16
16
  import { RunLog } from "./runstate/index.js";
17
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { join, resolve } from "node:path";
19
19
  import { DEFAULT_CONFIG, FakeGit, resumeAfterDoor, runLoop, startupSweep, } from "./loop/index.js";
20
20
  import { renderRunReport } from "./report/index.js";
@@ -23,16 +23,23 @@ import { runDoctor } from "./doctor/index.js";
23
23
  import { resolveCommandSpec } from "./harness/cli.js";
24
24
  import { StubRunner } from "./runners/stub/index.js";
25
25
  import { readdirSync } from "node:fs";
26
- import { analyzeRun, parseActivityLog } from "./analyze/index.js";
26
+ import { analyzeRun, parseActivityLog, } from "./analyze/index.js";
27
+ import { resolveResumeWorktreeRoot } from "./worktree/index.js";
28
+ import { loadOrchestrationConfig } from "./config/index.js";
29
+ import { matchesOnly, loadUnitStories } from "./only/index.js";
27
30
  const USAGE = `orchestrate — drive a spec DAG to merged with an agent fleet
28
31
 
29
32
  Usage:
30
33
  orchestrate run [--repo <path>] [--config <path>] [--routes <path>]
31
34
  [--only <id-prefix>] [--spool <hub-dir>] [--report <path>]
32
35
  [--watch] [--activity-log <dir>] [--no-activity-log] [--no-ledger]
36
+ [--worktree-root <dir>] [--threshold <rule>=<n>]
33
37
  real run against a checkout (default --repo: cwd);
34
38
  --only limits claims (harness next --only); --spool feeds the board (D1);
35
39
  --watch mirrors Cursor lane activity to stderr as well as the spool;
40
+ --worktree-root overrides the default per-run worktree root
41
+ (<repo>/.harness/worktrees/<run-id>);
42
+ --threshold <rule>=<n> overrides analysis thresholds (repeatable);
36
43
  activity capture is ON by default (<repo>/.harness/runs/<run-id>/activity.jsonl);
37
44
  analysis runs automatically at the end and appends one run.analyzed ledger
38
45
  event unless --no-ledger; --no-activity-log disables capture entirely
@@ -41,16 +48,21 @@ Usage:
41
48
  orchestrate resume --unit <id> [--repo <path>] [--routes <path>]
42
49
  [--spool <hub-dir>] [--report <path>] [--watch]
43
50
  [--activity-log <dir>] [--no-activity-log] [--no-ledger] [--resumes <run-id>]
51
+ [--worktree-root <dir>] [--threshold <rule>=<n>]
44
52
  after a signed door approval, reverify/review/land the unit;
45
- --resumes links the new run's activity log back to the run it continues
46
- orchestrate sweep [--repo <path>] [--routes <path>] [--json]
47
- startup crash-window check (stranded in-review, ledger-ahead-of-git)
53
+ --resumes links the new run's activity log back to the run it continues;
54
+ --worktree-root overrides the resolved prior-run worktree root;
55
+ --threshold <rule>=<n> overrides analysis thresholds (repeatable)
56
+ orchestrate sweep [--repo <path>] [--routes <path>] [--json] [--no-ledger]
57
+ startup crash-window check (interrupted runs, stranded in-review, ledger-ahead-of-git)
48
58
  orchestrate demo [--report <path>]
49
59
  the M1 dry loop against a fake DAG, prints RunState
50
60
  orchestrate analyze [--run <id> | --last | --log <path>] [--repo <path>]
61
+ [--config <path>] [--threshold <rule>=<n>]
51
62
  [--out <path>] [--json] [--no-ledger]
52
63
  synthesize a captured activity.jsonl into findings (P1);
53
64
  --run/--last resolve under <repo>/.harness/runs/, or pass --log directly;
65
+ --threshold <rule>=<n> overrides analysis thresholds (repeatable);
54
66
  writes summary.json next to the log unless --out is given
55
67
  orchestrate report --state <run-state.json> [--out <path>]
56
68
  render the end-of-run HTML from a saved RunState (D2)
@@ -89,7 +101,9 @@ function buildStubRunner(repo, config, stubConfig) {
89
101
  const stub = new StubRunner({
90
102
  cwd: repo,
91
103
  baseBranch: config.baseBranch,
92
- ...(Object.keys(stubConfig.afterImplement ?? {}).length ? { afterImplement } : {}),
104
+ ...(Object.keys(stubConfig.afterImplement ?? {}).length
105
+ ? { afterImplement }
106
+ : {}),
93
107
  });
94
108
  for (const [unitId, mode] of Object.entries(stubConfig.scripts ?? {})) {
95
109
  if (mode === "out-of-scope")
@@ -116,6 +130,55 @@ function flag(name) {
116
130
  const i = process.argv.indexOf(`--${name}`);
117
131
  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
118
132
  }
133
+ function repeatFlags(name) {
134
+ const out = [];
135
+ for (let i = 0; i < process.argv.length; i++) {
136
+ if (process.argv[i] === `--${name}` && process.argv[i + 1]) {
137
+ out.push(process.argv[i + 1]);
138
+ i++;
139
+ }
140
+ else if (process.argv[i]?.startsWith(`--${name}=`)) {
141
+ out.push(process.argv[i].slice(`--${name}=`.length));
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ function parseCliThresholds() {
147
+ const raw = repeatFlags("threshold");
148
+ if (raw.length === 0)
149
+ return null;
150
+ const thresholds = {};
151
+ for (const item of raw) {
152
+ const eqIdx = item.indexOf("=");
153
+ if (eqIdx === -1) {
154
+ throw new Error(`invalid --threshold format "${item}", expected <rule>=<n>`);
155
+ }
156
+ const rule = item.slice(0, eqIdx).trim();
157
+ const valStr = item.slice(eqIdx + 1).trim();
158
+ const val = Number(valStr);
159
+ if (!Number.isFinite(val) || val < 0) {
160
+ throw new Error(`invalid threshold value "${valStr}" for rule "${rule}", expected a non-negative number`);
161
+ }
162
+ if (rule === "high-turn-count" || rule === "highTurnCount") {
163
+ thresholds.highTurnCount = val;
164
+ }
165
+ else if (rule === "explore-before-edit" || rule === "exploreBeforeEdit") {
166
+ thresholds.exploreBeforeEdit = val;
167
+ }
168
+ else {
169
+ throw new Error(`unknown threshold rule "${rule}" — expected "high-turn-count" or "explore-before-edit"`);
170
+ }
171
+ }
172
+ return thresholds;
173
+ }
174
+ function resolveThresholds(configThresholds, cliThresholds) {
175
+ if (!configThresholds && !cliThresholds)
176
+ return undefined;
177
+ return {
178
+ ...configThresholds,
179
+ ...cliThresholds,
180
+ };
181
+ }
119
182
  function summarise(state, analysis) {
120
183
  process.stdout.write(JSON.stringify(state, null, 2) + "\n");
121
184
  process.stdout.write(`\noutcome=${state.outcome} merged=${state.counts.merged}/${state.dagSize} ` +
@@ -131,9 +194,9 @@ function summarise(state, analysis) {
131
194
  }
132
195
  }
133
196
  /** the `run.analyzed` body — metrics and rule ids only, no free text (P5.3: nothing for redaction to strip). */
134
- function runAnalyzedBody(summary) {
197
+ function runAnalyzedBody(summary, fallbackRunId) {
135
198
  return {
136
- run_id: summary.runId,
199
+ run_id: summary.runId || fallbackRunId || "unknown-run",
137
200
  units: summary.units.map((u) => ({
138
201
  unit_id: u.unitId,
139
202
  turns_total: u.turnsTotal,
@@ -160,7 +223,11 @@ async function appendRunAnalyzed(harness, runId, summary) {
160
223
  // doubles for both (and, since `session` is unset, for `actor.id` too —
161
224
  // assembleRun already sets actorId to runId). Redundant with body.run_id,
162
225
  // but harmless — not a copy-paste bug.
163
- await harness.ledgerAppend({ type: "run.analyzed", unitId: runId, body: runAnalyzedBody(summary) });
226
+ await harness.ledgerAppend({
227
+ type: "run.analyzed",
228
+ unitId: runId,
229
+ body: runAnalyzedBody(summary, runId),
230
+ });
164
231
  }
165
232
  /**
166
233
  * P2 — runs automatically at the end of `orchestrate run`/`resume`. A no-op
@@ -168,7 +235,7 @@ async function appendRunAnalyzed(harness, runId, summary) {
168
235
  * isn't a silent surprise — we tell the operator why. Appends one bounded
169
236
  * `run.analyzed` ledger event unless `--no-ledger` is passed.
170
237
  */
171
- async function runAutoAnalyze(deps, repo, runId, state) {
238
+ async function runAutoAnalyze(deps, repo, runId, state, cliThresholds) {
172
239
  if (!deps.activityLog) {
173
240
  process.stderr.write("orchestrate: activity capture was disabled — skipping analysis\n");
174
241
  return null;
@@ -176,7 +243,12 @@ async function runAutoAnalyze(deps, repo, runId, state) {
176
243
  const events = deps.activityLog.events(); // in-process — no re-read of the file needed
177
244
  const costByUnit = new Map(state.lanes.map((l) => [l.unitId, l.cost]));
178
245
  const logRef = `runs/${runId}/activity.jsonl`;
179
- const summary = analyzeRun(events, { logRef, costByUnit });
246
+ const thresholds = resolveThresholds(deps.config.analyze?.thresholds, cliThresholds);
247
+ const summary = analyzeRun(events, {
248
+ logRef,
249
+ costByUnit,
250
+ ...(thresholds ? { thresholds } : {}),
251
+ });
180
252
  const summaryPath = join(repo, ".harness", "runs", runId, "summary.json");
181
253
  writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
182
254
  if (!hasFlag("no-ledger")) {
@@ -211,10 +283,17 @@ function report() {
211
283
  async function demo() {
212
284
  const harness = new FakeHarness([
213
285
  { id: "a", scope: ["src/a/**"], mergeFacts: { door: "two-way" } },
214
- { id: "b", deps: ["a"], scope: ["src/b/**"], mergeFacts: { door: "two-way" } },
286
+ {
287
+ id: "b",
288
+ deps: ["a"],
289
+ scope: ["src/b/**"],
290
+ mergeFacts: { door: "two-way" },
291
+ },
215
292
  { id: "spike", mode: "explore", scope: ["src/spike/**"] },
216
293
  ]);
217
- const runners = new Map([["claude", new FakeRunner({ name: "claude" })]]);
294
+ const runners = new Map([
295
+ ["claude", new FakeRunner({ name: "claude" })],
296
+ ]);
218
297
  const deps = {
219
298
  harness,
220
299
  routes: new FakeRouteSource(),
@@ -237,6 +316,14 @@ function hasFlag(name) {
237
316
  return process.argv.includes(`--${name}`);
238
317
  }
239
318
  async function realRun() {
319
+ let cliThresholds;
320
+ try {
321
+ cliThresholds = parseCliThresholds();
322
+ }
323
+ catch (e) {
324
+ process.stderr.write(`orchestrate run: ${e.message}\n`);
325
+ return 2;
326
+ }
240
327
  const configPath = flag("config");
241
328
  const routesPath = flag("routes");
242
329
  const spoolDir = flag("spool");
@@ -244,6 +331,7 @@ async function realRun() {
244
331
  const watch = hasFlag("watch");
245
332
  const activityLogDir = flag("activity-log");
246
333
  const noActivityLog = hasFlag("no-activity-log");
334
+ const worktreeRoot = flag("worktree-root");
247
335
  let assembled;
248
336
  try {
249
337
  assembled = assembleRun({
@@ -255,6 +343,7 @@ async function realRun() {
255
343
  ...(watch ? { watch } : {}),
256
344
  ...(activityLogDir ? { activityLogDir } : {}),
257
345
  ...(noActivityLog ? { activityLog: false } : {}),
346
+ ...(worktreeRoot ? { worktreeRoot } : {}),
258
347
  });
259
348
  }
260
349
  catch (e) {
@@ -264,18 +353,37 @@ async function realRun() {
264
353
  const repo = resolve(flag("repo") ?? process.cwd());
265
354
  applyStubRunners(assembled.deps, repo);
266
355
  const { deps, runId, systemId, specRef } = assembled;
267
- const dagSize = (await deps.harness.units()).length;
356
+ const allUnits = await deps.harness.units();
357
+ const stories = loadUnitStories({ repo });
358
+ if (onlyPrefix &&
359
+ !allUnits.some((u) => matchesOnly({
360
+ id: u.id,
361
+ story: u.story ?? stories.get(u.id),
362
+ }, onlyPrefix))) {
363
+ process.stderr.write(`orchestrate run: no units match --only prefix "${onlyPrefix}"\n`);
364
+ return 1;
365
+ }
366
+ const dagSize = allUnits.length;
268
367
  const state = await runLoop(deps, { specRef, systemId, dagSize });
269
- const analysis = await runAutoAnalyze(deps, repo, runId, state);
368
+ const analysis = await runAutoAnalyze(deps, repo, runId, state, cliThresholds);
270
369
  summarise(state, analysis);
271
370
  return exitCodeForRunState(state);
272
371
  }
273
372
  async function resume() {
373
+ let cliThresholds;
374
+ try {
375
+ cliThresholds = parseCliThresholds();
376
+ }
377
+ catch (e) {
378
+ process.stderr.write(`orchestrate resume: ${e.message}\n`);
379
+ return 2;
380
+ }
274
381
  const unitId = flag("unit");
275
382
  if (!unitId) {
276
383
  process.stderr.write("orchestrate resume --unit <id> [--repo <path>] [--routes <path>]\n");
277
384
  return 2;
278
385
  }
386
+ const repo = resolve(flag("repo") ?? process.cwd());
279
387
  const configPath = flag("config");
280
388
  const routesPath = flag("routes");
281
389
  const spoolDir = flag("spool");
@@ -284,10 +392,19 @@ async function resume() {
284
392
  const activityLogDir = flag("activity-log");
285
393
  const noActivityLog = hasFlag("no-activity-log");
286
394
  const resumesRunId = flag("resumes");
395
+ const explicitWorktreeRoot = flag("worktree-root");
396
+ let worktreeRoot = explicitWorktreeRoot;
397
+ if (!worktreeRoot) {
398
+ worktreeRoot = await resolveResumeWorktreeRoot({
399
+ repo,
400
+ unitId,
401
+ resumesRunId,
402
+ });
403
+ }
287
404
  let assembled;
288
405
  try {
289
406
  assembled = assembleRun({
290
- repo: flag("repo") ?? process.cwd(),
407
+ repo,
291
408
  ...(configPath ? { configPath } : {}),
292
409
  ...(routesPath ? { routesPath } : {}),
293
410
  ...(onlyPrefix ? { onlyPrefix } : {}),
@@ -296,13 +413,13 @@ async function resume() {
296
413
  ...(activityLogDir ? { activityLogDir } : {}),
297
414
  ...(noActivityLog ? { activityLog: false } : {}),
298
415
  ...(resumesRunId ? { resumesRunId } : {}),
416
+ ...(worktreeRoot ? { worktreeRoot } : {}),
299
417
  });
300
418
  }
301
419
  catch (error) {
302
420
  process.stderr.write(`orchestrate resume: ${error.message}\n`);
303
421
  return 2;
304
422
  }
305
- const repo = resolve(flag("repo") ?? process.cwd());
306
423
  applyStubRunners(assembled.deps, repo);
307
424
  const dagSize = (await assembled.deps.harness.units()).length;
308
425
  const result = await resumeAfterDoor(unitId, assembled.deps);
@@ -317,33 +434,95 @@ async function resume() {
317
434
  systemId: assembled.systemId,
318
435
  dagSize,
319
436
  });
320
- const analysis = await runAutoAnalyze(assembled.deps, repo, assembled.runId, state);
437
+ const analysis = await runAutoAnalyze(assembled.deps, repo, assembled.runId, state, cliThresholds);
321
438
  summarise(state, analysis);
322
439
  return exitCodeForRunState(state);
323
440
  }
441
+ async function catchUpInterruptedRuns(repo, harness, configThresholds) {
442
+ const runsRoot = join(repo, ".harness", "runs");
443
+ let entries;
444
+ try {
445
+ entries = readdirSync(runsRoot, { withFileTypes: true })
446
+ .filter((d) => d.isDirectory())
447
+ .map((d) => d.name)
448
+ .sort();
449
+ }
450
+ catch {
451
+ return [];
452
+ }
453
+ const analyzed = [];
454
+ for (const runId of entries) {
455
+ const runDir = join(runsRoot, runId);
456
+ const activityPath = join(runDir, "activity.jsonl");
457
+ const summaryPath = join(runDir, "summary.json");
458
+ if (existsSync(summaryPath))
459
+ continue;
460
+ if (!existsSync(activityPath))
461
+ continue;
462
+ let content;
463
+ try {
464
+ content = readFileSync(activityPath, "utf8");
465
+ }
466
+ catch {
467
+ continue;
468
+ }
469
+ let events;
470
+ try {
471
+ events = parseActivityLog(content);
472
+ }
473
+ catch {
474
+ continue;
475
+ }
476
+ const logRef = `runs/${runId}/activity.jsonl`;
477
+ const summary = analyzeRun(events, {
478
+ logRef,
479
+ ...(configThresholds ? { thresholds: configThresholds } : {}),
480
+ });
481
+ writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
482
+ if (!hasFlag("no-ledger")) {
483
+ try {
484
+ await appendRunAnalyzed(harness, runId, summary);
485
+ }
486
+ catch (e) {
487
+ process.stderr.write(`orchestrate sweep: run.analyzed ledger append failed for ${runId}: ${e.message}\n`);
488
+ }
489
+ }
490
+ analyzed.push(runId);
491
+ }
492
+ return analyzed;
493
+ }
324
494
  async function sweep() {
495
+ const repo = resolve(flag("repo") ?? process.cwd());
325
496
  const configPath = flag("config");
326
497
  const routesPath = flag("routes");
327
498
  const onlyPrefix = flag("only");
499
+ const worktreeRoot = flag("worktree-root");
328
500
  let assembled;
329
501
  try {
330
502
  assembled = assembleRun({
331
- repo: flag("repo") ?? process.cwd(),
503
+ repo,
332
504
  ...(configPath ? { configPath } : {}),
333
505
  ...(routesPath ? { routesPath } : {}),
334
506
  ...(onlyPrefix ? { onlyPrefix } : {}),
507
+ ...(worktreeRoot ? { worktreeRoot } : {}),
508
+ activityLog: false,
335
509
  });
336
510
  }
337
511
  catch (error) {
338
512
  process.stderr.write(`orchestrate sweep: ${error.message}\n`);
339
513
  return 2;
340
514
  }
341
- applyStubRunners(assembled.deps, resolve(flag("repo") ?? process.cwd()));
515
+ applyStubRunners(assembled.deps, repo);
516
+ const analyzed = await catchUpInterruptedRuns(repo, assembled.deps.harness, assembled.deps.config.analyze?.thresholds);
342
517
  const halts = await startupSweep(assembled.deps);
343
518
  if (process.argv.includes("--json")) {
344
- process.stdout.write(JSON.stringify(halts, null, 2) + "\n");
519
+ const payload = halts.length > 0 && analyzed.length === 0 ? halts : { halts, analyzed };
520
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
345
521
  }
346
522
  else {
523
+ for (const runId of analyzed) {
524
+ process.stdout.write(` ✓ caught up interrupted run ${runId}\n`);
525
+ }
347
526
  for (const halt of halts) {
348
527
  process.stderr.write(` ⛔ ${halt.kind} ${halt.unitId ?? ""}: ${halt.message}\n`);
349
528
  }
@@ -359,7 +538,10 @@ function findLastRunId(runsRoot) {
359
538
  catch {
360
539
  return undefined;
361
540
  }
362
- return entries.filter((e) => e.startsWith("run-")).sort().at(-1);
541
+ return entries
542
+ .filter((e) => e.startsWith("run-"))
543
+ .sort()
544
+ .at(-1);
363
545
  }
364
546
  async function analyze() {
365
547
  const repo = resolve(flag("repo") ?? process.cwd());
@@ -383,8 +565,40 @@ async function analyze() {
383
565
  process.stderr.write(`orchestrate analyze: cannot read ${logPath}: ${e.message}\n`);
384
566
  return 2;
385
567
  }
568
+ const configPath = flag("config") ?? join(repo, "orchestration.json");
569
+ let fileConfig;
570
+ if (flag("config")) {
571
+ try {
572
+ fileConfig = loadOrchestrationConfig(configPath);
573
+ }
574
+ catch (e) {
575
+ process.stderr.write(`orchestrate analyze: ${e.message}\n`);
576
+ return 2;
577
+ }
578
+ }
579
+ else if (existsSync(configPath)) {
580
+ try {
581
+ fileConfig = loadOrchestrationConfig(configPath);
582
+ }
583
+ catch (e) {
584
+ process.stderr.write(`orchestrate analyze: ${e.message}\n`);
585
+ return 2;
586
+ }
587
+ }
588
+ let cliThresholds;
589
+ try {
590
+ cliThresholds = parseCliThresholds();
591
+ }
592
+ catch (e) {
593
+ process.stderr.write(`orchestrate analyze: ${e.message}\n`);
594
+ return 2;
595
+ }
596
+ const thresholds = resolveThresholds(fileConfig?.analyze?.thresholds, cliThresholds);
386
597
  const logRef = runId ? `runs/${runId}/activity.jsonl` : logPath;
387
- const summary = analyzeRun(events, { logRef });
598
+ const summary = analyzeRun(events, {
599
+ logRef,
600
+ ...(thresholds ? { thresholds } : {}),
601
+ });
388
602
  const outPath = flag("out") ?? (runId ? join(runsRoot, runId, "summary.json") : undefined);
389
603
  if (outPath)
390
604
  writeFileSync(outPath, JSON.stringify(summary, null, 2));
@@ -21,7 +21,14 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
21
21
  if (f.forge !== undefined && f.forge !== "local" && f.forge !== "github") {
22
22
  throw new ConfigError(`${source}: forge must be "local" or "github"`);
23
23
  }
24
- for (const k of ["maxParallel", "retryBudget", "maxRunCost", "maxRunDuration"]) {
24
+ for (const k of [
25
+ "maxParallel",
26
+ "retryBudget",
27
+ "maxRunCost",
28
+ "maxRunDuration",
29
+ "maxUnitCost",
30
+ "maxUnitDuration",
31
+ ]) {
25
32
  if (f[k] !== undefined && (typeof f[k] !== "number" || f[k] < 0)) {
26
33
  throw new ConfigError(`${source}: ${k} must be a non-negative number`);
27
34
  }
@@ -30,6 +37,22 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
30
37
  if (enabled !== undefined && (!Array.isArray(enabled) || enabled.some((e) => typeof e !== "string"))) {
31
38
  throw new ConfigError(`${source}: runners.enabled must be an array of strings`);
32
39
  }
40
+ if (f.analyze !== undefined) {
41
+ if (f.analyze === null || typeof f.analyze !== "object" || Array.isArray(f.analyze)) {
42
+ throw new ConfigError(`${source}: analyze must be an object`);
43
+ }
44
+ const t = f.analyze.thresholds;
45
+ if (t !== undefined) {
46
+ if (t === null || typeof t !== "object" || Array.isArray(t)) {
47
+ throw new ConfigError(`${source}: analyze.thresholds must be an object`);
48
+ }
49
+ for (const k of ["highTurnCount", "exploreBeforeEdit"]) {
50
+ if (t[k] !== undefined && (typeof t[k] !== "number" || t[k] < 0)) {
51
+ throw new ConfigError(`${source}: analyze.thresholds.${k} must be a non-negative number`);
52
+ }
53
+ }
54
+ }
55
+ }
33
56
  const cfg = {
34
57
  forge: f.forge ?? DEFAULT_CONFIG.forge,
35
58
  baseBranch: f.baseBranch ?? DEFAULT_CONFIG.baseBranch,
@@ -44,6 +67,26 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
44
67
  cfg.maxRunCost = f.maxRunCost;
45
68
  if (f.maxRunDuration !== undefined)
46
69
  cfg.maxRunDuration = f.maxRunDuration;
70
+ if (f.maxUnitCost !== undefined)
71
+ cfg.maxUnitCost = f.maxUnitCost;
72
+ if (f.maxUnitDuration !== undefined)
73
+ cfg.maxUnitDuration = f.maxUnitDuration;
74
+ if (f.analyze !== undefined) {
75
+ cfg.analyze = {
76
+ ...(f.analyze.thresholds !== undefined
77
+ ? {
78
+ thresholds: {
79
+ ...(f.analyze.thresholds.highTurnCount !== undefined
80
+ ? { highTurnCount: f.analyze.thresholds.highTurnCount }
81
+ : {}),
82
+ ...(f.analyze.thresholds.exploreBeforeEdit !== undefined
83
+ ? { exploreBeforeEdit: f.analyze.thresholds.exploreBeforeEdit }
84
+ : {}),
85
+ },
86
+ }
87
+ : {}),
88
+ };
89
+ }
47
90
  return cfg;
48
91
  }
49
92
  export function loadOrchestrationConfig(path) {