harnery 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,10 @@ interface WorkflowRunOpts {
25
25
  json?: boolean;
26
26
  }
27
27
 
28
+ interface WorkflowProofOpts {
29
+ json?: boolean;
30
+ }
31
+
28
32
  export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
29
33
  const registry = createBuiltinHarnessRegistry();
30
34
  const harnesses = registry.ids();
@@ -35,7 +39,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
35
39
  workflow
36
40
  .command("run <script>")
37
41
  .description(
38
- "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
42
+ "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log, evidence}) => …`). " +
39
43
  "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
40
44
  )
41
45
  .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
@@ -99,6 +103,14 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
99
103
  maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
100
104
  concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
101
105
  cwd: opts.cwd,
106
+ harnessEvidence: Object.fromEntries(
107
+ registry
108
+ .list()
109
+ .map((adapter) => [
110
+ adapter.profile.id,
111
+ { toolEvidence: adapter.profile.capabilities.toolEvidence },
112
+ ]),
113
+ ),
102
114
  });
103
115
  if (opts.json) {
104
116
  emit.config({ format: "json" });
@@ -112,11 +124,51 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
112
124
  emit.text(
113
125
  `run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
114
126
  `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
127
+ `acceptance: ${report.acceptance.satisfied} satisfied, ${report.acceptance.unsatisfied} unsatisfied, ` +
128
+ `${report.acceptance.unknown} unknown\n` +
115
129
  `journal: ${report.journalPath}\n` +
130
+ `proof: ${report.proofPath}\n` +
116
131
  `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
117
132
  );
118
133
  } catch (err) {
119
- emit.error({ code: "workflow_failed", message: (err as Error).message });
134
+ const proofPath =
135
+ typeof err === "object" && err !== null && "proofPath" in err
136
+ ? String(err.proofPath)
137
+ : undefined;
138
+ emit.error({
139
+ code: "workflow_failed",
140
+ message: `${(err as Error).message}${proofPath ? `\nproof: ${proofPath}` : ""}`,
141
+ });
142
+ process.exit(1);
143
+ }
144
+ });
145
+
146
+ workflow
147
+ .command("proof <run-id>")
148
+ .description("Show the bounded proof packet for a completed workflow run.")
149
+ .option("--json", "Emit the stored proof packet as JSON")
150
+ .action(async (runId: string, opts: WorkflowProofOpts) => {
151
+ const coordRoot = findCoordRoot();
152
+ if (!coordRoot) {
153
+ emit.error({
154
+ code: "no_coord_root",
155
+ message: "no .harnery/ coordination root found; run `init` first",
156
+ });
157
+ process.exit(1);
158
+ }
159
+ try {
160
+ const { readWorkflowProof, renderWorkflowProof } = await import(
161
+ "../core/workflow/proof.ts"
162
+ );
163
+ const proof = readWorkflowProof(coordRoot, runId);
164
+ if (opts.json) {
165
+ emit.config({ format: "json" });
166
+ emit.data(proof);
167
+ return;
168
+ }
169
+ emit.text(renderWorkflowProof(proof));
170
+ } catch (err) {
171
+ emit.error({ code: "workflow_proof_failed", message: (err as Error).message });
120
172
  process.exit(1);
121
173
  }
122
174
  });
@@ -21,16 +21,28 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
+ import { snapshotRepo } from "../context/index.ts";
24
25
  import { type BillingProbe, probeBilling } from "./billing.ts";
26
+ import {
27
+ buildWorkflowProof,
28
+ createEvidenceRecord,
29
+ digestResult,
30
+ normalizeWorkflowMeta,
31
+ writeWorkflowProof,
32
+ } from "./proof.ts";
25
33
  import type {
26
34
  AgentOpts,
27
35
  EngineOpts,
28
36
  HarnessName,
29
37
  RunReport,
30
38
  SpawnResult,
39
+ WorkflowAgentProof,
31
40
  WorkflowContext,
41
+ WorkflowEvidenceRecord,
32
42
  WorkflowModule,
43
+ WorkflowProof,
33
44
  } from "./types.ts";
45
+ import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
34
46
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
35
47
 
36
48
  const DEFAULT_MAX_AGENTS = 50;
@@ -39,24 +51,40 @@ const DEFAULT_MAX_ATTEMPTS = 2;
39
51
  const DEFAULT_TIMEOUT_MS = 300_000;
40
52
  const DEFAULT_MAX_TURNS = 25;
41
53
 
54
+ export class WorkflowRunError extends Error {
55
+ readonly runId: string;
56
+ readonly proofPath: string;
57
+
58
+ constructor(message: string, runId: string, proofPath: string, cause?: unknown) {
59
+ super(message, cause === undefined ? undefined : { cause });
60
+ this.name = "WorkflowRunError";
61
+ this.runId = runId;
62
+ this.proofPath = proofPath;
63
+ }
64
+ }
65
+
42
66
  export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport> {
43
67
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
44
68
  const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
45
69
  if (typeof mod.default !== "function") {
46
70
  throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
47
71
  }
48
- const name = mod.meta?.name ?? scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
72
+ const fallbackName = scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
73
+ const meta = normalizeWorkflowMeta(mod.meta, fallbackName);
74
+ const name = meta.name;
49
75
 
50
76
  const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
51
77
  const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
52
78
  mkdirSync(runDir, { recursive: true });
53
79
  const journalPath = join(runDir, "journal.jsonl");
80
+ const proofPath = join(runDir, "proof.json");
54
81
 
55
82
  const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
56
83
  const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
57
84
  const cwd = opts.cwd ?? opts.coordRoot;
58
85
  const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
59
86
  const defaultHarness: HarnessName = opts.defaultHarness ?? "claude-code";
87
+ const repoBefore = snapshotRepo(cwd);
60
88
 
61
89
  // Per-child fixed context overhead: children spawn in `cwd` and load its
62
90
  // repo-instructions file into their system prompt, cache-writing it once
@@ -79,10 +107,16 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
79
107
  let costUsd = 0;
80
108
  let currentStage = "";
81
109
  let agentSeq = 0;
110
+ let evidenceSeq = 0;
82
111
  const billingProbed = new Map<HarnessName, BillingProbe>();
112
+ const agentProofs = new Map<string, WorkflowAgentProof>();
113
+ const evidenceRecords: WorkflowEvidenceRecord[] = [];
114
+ const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
83
115
 
84
116
  const journal = (event: string, data: Record<string, unknown>): void => {
85
117
  const line = JSON.stringify({
118
+ schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
119
+ run_id: runId,
86
120
  ts: new Date().toISOString(),
87
121
  event,
88
122
  stage: currentStage,
@@ -119,7 +153,19 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
119
153
  }
120
154
  const id = `a${++agentSeq}`;
121
155
  const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
156
+ const proofLabel = agentOpts.label ?? id;
122
157
  const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
158
+ const agentProof: WorkflowAgentProof = {
159
+ id,
160
+ label: proofLabel,
161
+ stage: currentStage || undefined,
162
+ harness,
163
+ model: agentOpts.model,
164
+ status: "failed",
165
+ attempts: 0,
166
+ duration_ms: 0,
167
+ };
168
+ agentProofs.set(id, agentProof);
123
169
 
124
170
  // Call identity for resume: same stage + harness + model + effort + turns + schema
125
171
  // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
@@ -127,7 +173,16 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
127
173
  const cached = resumeCache.get(key);
128
174
  if (cached) {
129
175
  agentsCached++;
130
- journal("agent.cached", { id, label, key, kind: cached.kind });
176
+ agentProof.status = "cached";
177
+ agentProof.result = digestResult(cached.value, cached.kind);
178
+ journal("agent.cached", {
179
+ id,
180
+ label,
181
+ key,
182
+ harness,
183
+ model: agentOpts.model ?? null,
184
+ kind: cached.kind,
185
+ });
131
186
  log(
132
187
  `[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`,
133
188
  );
@@ -179,6 +234,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
179
234
  }
180
235
 
181
236
  if (agentsSpawned >= maxAgents) {
237
+ agentProof.error = `workflow agent cap reached (${maxAgents})`;
182
238
  throw new Error(
183
239
  `workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
184
240
  );
@@ -199,6 +255,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
199
255
 
200
256
  let attemptPrompt = prompt;
201
257
  let last: SpawnResult | null = null;
258
+ let agentCostUsd = 0;
202
259
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
203
260
  last = await spawner({
204
261
  prompt: attemptPrompt,
@@ -210,6 +267,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
210
267
  runId,
211
268
  subscriptionOnly: opts.subscriptionOnly,
212
269
  });
270
+ agentProof.attempts = attempt;
271
+ agentProof.duration_ms += last.durationMs;
272
+ agentCostUsd += last.costUsd ?? 0;
213
273
  costUsd += last.costUsd ?? 0;
214
274
 
215
275
  if (!last.ok) {
@@ -217,6 +277,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
217
277
  continue; // spawn-level failure: retry with the original prompt
218
278
  }
219
279
  if (!agentOpts.schema) {
280
+ agentProof.status = "succeeded";
281
+ agentProof.cost_usd =
282
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
283
+ agentProof.session_id = last.sessionId;
284
+ agentProof.result = digestResult(last.text, "text");
220
285
  journal("agent.end", {
221
286
  id,
222
287
  key,
@@ -236,6 +301,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
236
301
  ? [parsed.error]
237
302
  : validateAgainstSchema(parsed.value, agentOpts.schema);
238
303
  if (problems.length === 0) {
304
+ agentProof.status = "succeeded";
305
+ agentProof.cost_usd =
306
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
307
+ agentProof.session_id = last.sessionId;
308
+ agentProof.result = digestResult(parsed.value, "json");
239
309
  journal("agent.end", {
240
310
  id,
241
311
  key,
@@ -261,8 +331,15 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
261
331
  const reason = last?.ok
262
332
  ? `schema validation failed after ${maxAttempts} attempt(s)`
263
333
  : (last?.error ?? "spawn failed");
334
+ agentProof.cost_usd =
335
+ agentCostUsd > 0 || last?.costUsd !== undefined ? agentCostUsd : undefined;
336
+ agentProof.session_id = last?.sessionId;
337
+ agentProof.error = reason;
264
338
  journal("agent.failed", { id, error: reason });
265
- throw new Error(`agent ${id} (${label}): ${reason}`);
339
+ throw new Error(`agent ${proofLabel}: ${reason}`);
340
+ } catch (error) {
341
+ agentProof.error ??= (error as Error).message;
342
+ throw error;
266
343
  } finally {
267
344
  release();
268
345
  }
@@ -288,12 +365,68 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
288
365
  log(`[${name}] ── stage: ${title}`);
289
366
  };
290
367
 
291
- const ctx: WorkflowContext = { agent, parallel, stage, log };
368
+ const evidence = (input: Parameters<WorkflowContext["evidence"]>[0]): string => {
369
+ const record = createEvidenceRecord({
370
+ value: input,
371
+ sequence: evidenceSeq + 1,
372
+ acceptanceIds,
373
+ stage: currentStage || undefined,
374
+ });
375
+ evidenceSeq++;
376
+ evidenceRecords.push(record);
377
+ journal("evidence.recorded", { ...record });
378
+ return record.id;
379
+ };
380
+
381
+ const ctx: WorkflowContext = { agent, parallel, stage, log, evidence };
292
382
 
293
383
  const t0 = Date.now();
294
- journal("run.start", { name, script: absScript, max_agents: maxAgents, concurrency });
384
+ const startedAt = new Date(t0).toISOString();
385
+ journal("run.start", {
386
+ name,
387
+ script: absScript,
388
+ objective: meta.objective ?? null,
389
+ acceptance: meta.acceptance,
390
+ max_agents: maxAgents,
391
+ concurrency,
392
+ });
295
393
  try {
296
394
  const result = await mod.default(ctx);
395
+ const endedAt = new Date().toISOString();
396
+ const durationMs = Date.now() - t0;
397
+ journal("run.end", {
398
+ ok: true,
399
+ agents: agentsSpawned,
400
+ cached: agentsCached,
401
+ cost_usd: round4(costUsd),
402
+ duration_ms: durationMs,
403
+ });
404
+ let proof: WorkflowProof;
405
+ try {
406
+ proof = buildWorkflowProof({
407
+ runId,
408
+ meta,
409
+ status: "succeeded",
410
+ startedAt,
411
+ endedAt,
412
+ durationMs,
413
+ journalPath,
414
+ before: repoBefore,
415
+ after: snapshotRepo(cwd),
416
+ agents: Array.from(agentProofs.values()),
417
+ evidence: evidenceRecords,
418
+ harnessEvidence: opts.harnessEvidence,
419
+ result,
420
+ });
421
+ writeWorkflowProof(proofPath, proof);
422
+ } catch (error) {
423
+ throw new WorkflowRunError(
424
+ `workflow completed but its proof packet could not be written: ${(error as Error).message}`,
425
+ runId,
426
+ proofPath,
427
+ error,
428
+ );
429
+ }
297
430
  const report: RunReport = {
298
431
  runId,
299
432
  name,
@@ -301,31 +434,55 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
301
434
  agentsSpawned,
302
435
  agentsCached,
303
436
  costUsd: round4(costUsd),
304
- durationMs: Date.now() - t0,
437
+ durationMs,
305
438
  journalPath,
439
+ proofPath,
440
+ acceptance: proof.acceptance.summary,
306
441
  contextTokensPerChildEstimate,
307
442
  billing: Array.from(billingProbed.values()).map((p) => ({
308
443
  harness: p.harness,
309
444
  mode: opts.subscriptionOnly ? "subscription" : p.mode,
310
445
  })),
311
446
  };
312
- journal("run.end", {
313
- ok: true,
314
- agents: agentsSpawned,
315
- cached: agentsCached,
316
- cost_usd: report.costUsd,
317
- duration_ms: report.durationMs,
318
- });
319
447
  return report;
320
448
  } catch (err) {
449
+ if (err instanceof WorkflowRunError) throw err;
450
+ const endedAt = new Date().toISOString();
451
+ const durationMs = Date.now() - t0;
321
452
  journal("run.end", {
322
453
  ok: false,
323
454
  error: (err as Error).message,
324
455
  agents: agentsSpawned,
325
456
  cached: agentsCached,
326
457
  cost_usd: round4(costUsd),
458
+ duration_ms: durationMs,
327
459
  });
328
- throw err;
460
+ try {
461
+ const proof = buildWorkflowProof({
462
+ runId,
463
+ meta,
464
+ status: "failed",
465
+ startedAt,
466
+ endedAt,
467
+ durationMs,
468
+ journalPath,
469
+ before: repoBefore,
470
+ after: snapshotRepo(cwd),
471
+ agents: Array.from(agentProofs.values()),
472
+ evidence: evidenceRecords,
473
+ harnessEvidence: opts.harnessEvidence,
474
+ error: (err as Error).message,
475
+ });
476
+ writeWorkflowProof(proofPath, proof);
477
+ } catch (proofError) {
478
+ throw new WorkflowRunError(
479
+ `${(err as Error).message}; proof packet write also failed: ${(proofError as Error).message}`,
480
+ runId,
481
+ proofPath,
482
+ err,
483
+ );
484
+ }
485
+ throw new WorkflowRunError((err as Error).message, runId, proofPath, err);
329
486
  }
330
487
  }
331
488
 
@@ -0,0 +1,43 @@
1
+ export { runWorkflow, WorkflowRunError } from "./engine.ts";
2
+ export {
3
+ buildWorkflowProof,
4
+ createEvidenceRecord,
5
+ digestResult,
6
+ normalizeWorkflowMeta,
7
+ readWorkflowProof,
8
+ renderWorkflowProof,
9
+ rollupAcceptance,
10
+ writeWorkflowProof,
11
+ } from "./proof.ts";
12
+ export type {
13
+ AcceptanceCriterion,
14
+ AcceptanceResult,
15
+ AcceptanceStatus,
16
+ AcceptanceSummary,
17
+ AgentOpts,
18
+ EngineOpts,
19
+ EvidenceKind,
20
+ EvidenceSource,
21
+ EvidenceStatus,
22
+ HarnessEvidenceCapability,
23
+ HarnessEvidenceCoverage,
24
+ HarnessName,
25
+ ResultDigest,
26
+ RunReport,
27
+ Spawner,
28
+ SpawnRequest,
29
+ SpawnResult,
30
+ StageSchema,
31
+ WorkflowAgentProof,
32
+ WorkflowContext,
33
+ WorkflowEvidenceInput,
34
+ WorkflowEvidenceRecord,
35
+ WorkflowMeta,
36
+ WorkflowModule,
37
+ WorkflowProof,
38
+ WorkflowProofUnknown,
39
+ WorkflowRepoEvidence,
40
+ WorkflowRepoSnapshot,
41
+ WorkflowRunStatus,
42
+ } from "./types.ts";
43
+ export { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";