@mikenguyen69/harness 0.1.0-beta.2 → 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.
Files changed (44) hide show
  1. package/dist/cli.js +31 -9
  2. package/dist/help.js +82 -1
  3. package/dist/internal/orchestration/analyze/index.js +9 -5
  4. package/dist/internal/orchestration/cli.js +237 -23
  5. package/dist/internal/orchestration/config/index.js +44 -1
  6. package/dist/internal/orchestration/doctor/index.js +180 -114
  7. package/dist/internal/orchestration/harness/cli.js +14 -1
  8. package/dist/internal/orchestration/loop/index.js +309 -15
  9. package/dist/internal/orchestration/only/index.js +80 -0
  10. package/dist/internal/orchestration/prompts/index.js +23 -7
  11. package/dist/internal/orchestration/run/index.js +36 -3
  12. package/dist/internal/orchestration/runners/claude/index.js +22 -2
  13. package/dist/internal/orchestration/runners/codex/index.js +11 -3
  14. package/dist/internal/orchestration/runners/cursor/index.js +13 -3
  15. package/dist/internal/orchestration/runners/telemetry.js +2 -1
  16. package/dist/internal/orchestration/worktree/index.js +84 -2
  17. package/dist/internal/system/agentops/scope.js +25 -2
  18. package/dist/internal/system/cli.js +13 -0
  19. package/dist/internal/system/doors/cli.js +158 -20
  20. package/dist/internal/system/doors/index.js +70 -15
  21. package/dist/internal/system/init/index.js +96 -4
  22. package/dist/internal/system/ledger/index.js +138 -5
  23. package/dist/internal/system/provenance/cli.js +70 -0
  24. package/dist/internal/system/provenance/index.js +295 -3
  25. package/dist/internal/system/runner/index.js +16 -5
  26. package/dist/internal/system/sequencer/cli.js +70 -32
  27. package/dist/internal/system/sequencer/index.js +46 -1
  28. package/dist/internal/system/spec/cli.js +30 -3
  29. package/dist/internal/system/spec/project.js +29 -6
  30. package/dist/internal/system/status/cli.js +49 -0
  31. package/dist/internal/system/status/index.js +288 -0
  32. package/dist/internal/system/system/index.js +55 -8
  33. package/dist/internal/system/units/index.js +1 -0
  34. package/dist/internal/system/verify/cli.js +29 -1
  35. package/dist/internal/system/verify/index.js +42 -5
  36. package/dist/routes.js +2 -3
  37. package/package.json +1 -1
  38. package/schema/attestation.schema.json +134 -0
  39. package/schema/envelope.schema.json +12 -1
  40. package/schema/ledger-event.schema.json +1 -0
  41. package/schema/spec.schema.json +5 -0
  42. package/schema/system.schema.json +1 -1
  43. package/templates/target-kit/README.md +2 -1
  44. package/templates/target-kit/orchestration.json +0 -2
package/dist/cli.js CHANGED
@@ -8,19 +8,45 @@
8
8
  * hook-install policy lives here.
9
9
  */
10
10
  import { buildDispatchRequest, dispatch } from "./dispatch.js";
11
- import { USAGE } from "./help.js";
11
+ import { COMMAND_HELP, SESSION_HELP, SESSION_PHASES, USAGE, wantsHelp, } from "./help.js";
12
12
  import { resolveInternalClis } from "./paths.js";
13
13
  import { buildInternalArgv, resolveRoute } from "./routes.js";
14
14
  import { formatVersion } from "./version.js";
15
15
  export async function main(argv) {
16
- if (argv.includes("--help") || argv.includes("-h")) {
17
- process.stdout.write(USAGE);
18
- return 0;
19
- }
20
16
  if (argv.includes("--version") || argv.includes("-V")) {
21
17
  process.stdout.write(formatVersion());
22
18
  return 0;
23
19
  }
20
+ const first = argv[0];
21
+ const helpRequested = wantsHelp(argv);
22
+ // Bare `harness`, `harness --help`, `harness -h` — the operator-journey text.
23
+ // A help flag that is not the leading token belongs to a specific command.
24
+ if (first === undefined || first === "--help" || first === "-h") {
25
+ process.stdout.write(USAGE);
26
+ return 0;
27
+ }
28
+ // `harness session [<phase>]` — enumerate the phases, and classify an
29
+ // unrecognised one as an unknown *phase*, not an unknown command. Runs before
30
+ // the generic `<noun> --help` path so a bare `harness session` prints them too.
31
+ if (first === "session") {
32
+ const phase = argv[1];
33
+ if (phase === undefined || phase === "--help" || phase === "-h") {
34
+ process.stdout.write(SESSION_HELP + "\n");
35
+ return 0;
36
+ }
37
+ if (!SESSION_PHASES.includes(phase)) {
38
+ process.stderr.write(`harness session ${phase}: unknown phase — expected one of ${SESSION_PHASES.join(", ")}\n`);
39
+ return 2;
40
+ }
41
+ }
42
+ // `harness <noun> --help` / `harness <noun> -h` — that command's own usage.
43
+ if (helpRequested && (argv[1] === "--help" || argv[1] === "-h")) {
44
+ const synopsis = COMMAND_HELP[first];
45
+ if (synopsis !== undefined) {
46
+ process.stdout.write(synopsis + "\n");
47
+ return 0;
48
+ }
49
+ }
24
50
  const route = resolveRoute(argv);
25
51
  if (route === null) {
26
52
  process.stderr.write(argv.length === 0
@@ -28,10 +54,6 @@ export async function main(argv) {
28
54
  : `harness ${argv.join(" ")}: unknown command\nRun harness --help\n`);
29
55
  return argv.length === 0 ? 0 : 2;
30
56
  }
31
- if (route === "status-skeleton") {
32
- process.stderr.write("harness status: not implemented (skeleton)\n");
33
- return 1;
34
- }
35
57
  const clis = resolveInternalClis();
36
58
  const internalArgv = buildInternalArgv(route, argv);
37
59
  const request = buildDispatchRequest(route.product, internalArgv, {
package/dist/help.js CHANGED
@@ -5,7 +5,7 @@ Operator journey:
5
5
  harness doctor read-only preflight before a run
6
6
  harness run drive the spec DAG with an agent fleet
7
7
  harness resume continue after a signed door approval
8
- harness status project run and lane status from RunState
8
+ harness status read-only unit lanes from the ledger (+ run overlay)
9
9
  harness report run render the end-of-run HTML report
10
10
  harness report calibration aggregate verification envelopes (calibration input)
11
11
 
@@ -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
@@ -41,7 +42,87 @@ Integration (machine-facing, same binary):
41
42
 
42
43
  Usage:
43
44
  harness --help this text
45
+ harness <command> --help usage for one command
44
46
  harness --version facade version and internal build identity
45
47
  harness <command> [args…] dispatch to the owning internal CLI
46
48
  `;
49
+ /**
50
+ * The phases `harness session <phase>` accepts. The facade routes each to
51
+ * `agent session <phase>`; anything else is an unknown phase, not an unknown
52
+ * command.
53
+ */
54
+ export const SESSION_PHASES = [
55
+ "started",
56
+ "heartbeat",
57
+ "gate_fired",
58
+ "escalated",
59
+ "ended",
60
+ ];
61
+ export const SESSION_HELP = `harness session <phase> append session.<phase> evidence (event JSON on stdin)
62
+
63
+ phases: ${SESSION_PHASES.join(", ")}
64
+
65
+ e.g. harness session started < event.json`;
66
+ /**
67
+ * One synopsis per facade command noun, shown for `harness <noun> --help` /
68
+ * `harness <noun> -h` so subcommand help never falls through to the
69
+ * operator-journey text above. Keyed by the leading facade token.
70
+ */
71
+ export const COMMAND_HELP = {
72
+ init: `harness init scaffold component.toml, hooks, CI workflow, generated adapters
73
+ --audit read-only brownfield inventory (writes nothing)
74
+ --mode ratchet brownfield adoption against a frozen debt baseline`,
75
+ doctor: `harness doctor read-only preflight before a run
76
+ --repo <dir> --routes <routes.toml> [--spool <dir>] [--json]`,
77
+ run: `harness run drive the spec DAG with an agent fleet
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)`,
80
+ resume: `harness resume continue a run after a signed door approval
81
+ --unit <id> [--repo <dir>] [--routes <routes.toml>]`,
82
+ status: `harness status read-only unit lanes from the ledger, with a run-status spool overlay
83
+ [--ledger <dir>] [--spool <dir>] [--json]`,
84
+ report: `harness report <run|calibration>
85
+ report run --state <state.json> end-of-run HTML report (orchestration)
86
+ report calibration aggregate verification envelopes (system)`,
87
+ verify: `harness verify run all bound gates (cheap first, fail fast) and emit the signed envelope`,
88
+ gate: `harness gate --pre --file <path> ring-0 cheap gates before tool use`,
89
+ spec: `harness spec <check|materialize|project>
90
+ check lint specs, dep DAG, consumes/produces [--specs <dir>] [--system <system.toml>]
91
+ materialize write idempotent unit.planned facts --specs <dir> --system <system.toml> [--ledger <dir>] [--json]
92
+ project emit unit YAML from OpenSpec + projection --change <dir> [--openspec <dir>] [--out <dir>] [--json]`,
93
+ next: `harness next list units ready to claim (deps merged, unclaimed or stale)
94
+ [--only <id-prefix>] [--all | --in-review | --merged | --escalated] [--json]`,
95
+ claim: `harness claim <unit-id> --session <id> atomically take a ready unit
96
+ [--specs <dir>] [--system <path>] [--ledger <dir>] [--json] [--resume | --resume-after-door]`,
97
+ heartbeat: `harness heartbeat <unit-id> --session <id> keep a claim alive [--ledger <dir>]`,
98
+ merge: `harness merge <unit-id> <--check | --landed <sha>> authorize or record a landed merge
99
+ [--system <system.toml>] [facts…]`,
100
+ door: `harness door <list|show|approve|reject> open-decision surface
101
+ list [--sweep]
102
+ show <id>
103
+ approve <id> --reason "<who>: <why>" [--who <w>]
104
+ reject <id> --reason <why>`,
105
+ ledger: `harness ledger <append|read|verify-chain> the hash-chained record plane [--dir <path>]
106
+ append full event JSON document on stdin
107
+ read print the chain
108
+ verify-chain check hash linkage`,
109
+ board: `harness board <build|serve> local live dashboard
110
+ build [--ledger <dir>] [--spool <dir>] [--out board.html]
111
+ serve [--root <dir>]… [--port 4317] [--refresh 2000]`,
112
+ attest: `harness attest write/verify provenance attestations for a merge
113
+ write --unit <id> --landed <sha> [--ledger <dir>] [--out <dir>]
114
+ verify --unit <id> [--ledger <dir>] [--dir <dir>]`,
115
+ calibrate: `harness calibrate calibration jobs`,
116
+ digest: `harness digest digest surfaces`,
117
+ route: `harness route print the resolved route as JSON`,
118
+ escalate: `harness escalate raise session.escalated for the current unit`,
119
+ hook: `harness hook pre-tool-use agent PreToolUse handler (payload on stdin)`,
120
+ session: SESSION_HELP,
121
+ };
122
+ /** True when a `-h`/`--help` token appears before any bare `--` separator. */
123
+ export function wantsHelp(argv) {
124
+ const sep = argv.indexOf("--");
125
+ const scan = sep === -1 ? argv : argv.slice(0, sep);
126
+ return scan.includes("--help") || scan.includes("-h");
127
+ }
47
128
  //# sourceMappingURL=help.js.map
@@ -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",
@@ -186,7 +186,10 @@ export function findExploreBeforeEdit(unitEvents) {
186
186
  export function findVerifyHeavierThanImplement(unitEvents) {
187
187
  const implementTurns = unitEvents.filter((e) => IMPLEMENT_STEPS.has(e.step) && (e.kind === "tool" || e.kind === "text")).length;
188
188
  const reviewTurns = unitEvents.filter((e) => REVIEW_STEPS.has(e.step) && (e.kind === "tool" || e.kind === "text")).length;
189
- if (reviewTurns <= implementTurns || reviewTurns === 0)
189
+ // implementTurns === 0 is structural (a resume starting mid-pipeline, no
190
+ // implement step at all) — there is nothing to compare review against, not
191
+ // an imbalance. Symmetric with the reviewTurns === 0 guard below.
192
+ if (reviewTurns <= implementTurns || reviewTurns === 0 || implementTurns === 0)
190
193
  return [];
191
194
  return [
192
195
  {
@@ -203,6 +206,7 @@ export function analyzeRun(events, opts) {
203
206
  const runId = events.find((e) => e.t === "run.started")
204
207
  ?.run_id ?? "";
205
208
  const highTurnCount = opts.thresholds?.highTurnCount ?? DEFAULT_HIGH_TURN_COUNT;
209
+ const exploreBeforeEdit = opts.thresholds?.exploreBeforeEdit ?? DEFAULT_EXPLORE_BEFORE_EDIT;
206
210
  const stepsByUnit = new Map();
207
211
  const activityByUnit = new Map();
208
212
  for (const e of events) {
@@ -224,7 +228,7 @@ export function analyzeRun(events, opts) {
224
228
  const steps = stepsByUnit.get(unitId) ?? [];
225
229
  const activity = activityByUnit.get(unitId) ?? [];
226
230
  units.push(unitMetrics(unitId, steps, activity, opts.costByUnit));
227
- 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));
228
232
  }
229
233
  return {
230
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));