@kontourai/flow-agents 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +308 -47
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +67 -13
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +801 -97
- package/build/src/cli/workflow.js +290 -42
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +41 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +212 -63
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +325 -11
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +331 -61
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +62 -13
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +194 -8
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +724 -97
- package/src/cli/workflow.ts +277 -40
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
|
|
@@ -309,18 +310,28 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
|
309
310
|
return path.join(assignmentDir, `.${sanitized}.lockdir`);
|
|
310
311
|
}
|
|
311
312
|
|
|
313
|
+
// Lock age is adjudicated by the current contender, never by metadata written
|
|
314
|
+
// by the lock owner. The environment is only an operator tuning input; clamp it
|
|
315
|
+
// so a caller cannot turn a transient owner-file write into immediate takeover.
|
|
316
|
+
const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
|
|
317
|
+
const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
|
|
318
|
+
const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
|
|
319
|
+
|
|
320
|
+
function trustedSubjectLockStaleMs(): number {
|
|
321
|
+
const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
|
|
322
|
+
if (!Number.isFinite(configured)) return SUBJECT_LOCK_STALE_DEFAULT_MS;
|
|
323
|
+
return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
|
|
324
|
+
}
|
|
325
|
+
|
|
312
326
|
/**
|
|
313
327
|
* F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
|
|
314
328
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
315
329
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
316
330
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
317
|
-
* races against the built CLI).
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
322
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
323
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
331
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
332
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
333
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
334
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
324
335
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
325
336
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
326
337
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|
|
@@ -331,22 +342,31 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
|
|
|
331
342
|
*/
|
|
332
343
|
export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
|
|
333
344
|
const lockDir = subjectLockDir(artifactRoot, subjectId);
|
|
334
|
-
const staleMs =
|
|
345
|
+
const staleMs = trustedSubjectLockStaleMs();
|
|
346
|
+
const token = randomBytes(16).toString("hex");
|
|
347
|
+
const ownerFile = path.join(lockDir, "owner.json");
|
|
335
348
|
const deadline = Date.now() + 30000;
|
|
336
349
|
while (true) {
|
|
350
|
+
let createdLockDir = false;
|
|
337
351
|
try {
|
|
338
352
|
fs.mkdirSync(lockDir);
|
|
353
|
+
createdLockDir = true;
|
|
354
|
+
fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
|
|
339
355
|
break;
|
|
340
356
|
} catch (error) {
|
|
341
357
|
const lockError = error as NodeJS.ErrnoException;
|
|
358
|
+
if (createdLockDir) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
342
359
|
if (lockError.code !== "EEXIST") {
|
|
343
360
|
throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
|
|
344
361
|
}
|
|
345
362
|
try {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
363
|
+
const owner = readSubjectLockOwner(ownerFile);
|
|
364
|
+
const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
|
|
365
|
+
if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
|
|
366
|
+
throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
369
|
+
throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
|
|
350
370
|
}
|
|
351
371
|
} catch (statError) {
|
|
352
372
|
if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
|
|
@@ -358,7 +378,12 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
|
|
|
358
378
|
sleepSync(20);
|
|
359
379
|
}
|
|
360
380
|
}
|
|
361
|
-
|
|
381
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
382
|
+
const ownsLock = (): boolean => readSubjectLockOwner(ownerFile)?.token === token;
|
|
383
|
+
const release = (): void => {
|
|
384
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
385
|
+
if (ownsLock()) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
386
|
+
};
|
|
362
387
|
let result: T;
|
|
363
388
|
try {
|
|
364
389
|
result = body();
|
|
@@ -367,12 +392,36 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
|
|
|
367
392
|
throw error;
|
|
368
393
|
}
|
|
369
394
|
if (result && typeof (result as { then?: unknown }).then === "function") {
|
|
395
|
+
// An async owner can legitimately hold the lock longer than the stale-lock
|
|
396
|
+
// threshold while an authority-bound command is running. Keep its mtime fresh
|
|
397
|
+
// so lifecycle operations and takeovers continue to observe the live lock.
|
|
398
|
+
const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
|
|
399
|
+
heartbeat = setInterval(() => {
|
|
400
|
+
try {
|
|
401
|
+
if (!ownsLock()) return;
|
|
402
|
+
const timestamp = new Date();
|
|
403
|
+
fs.utimesSync(ownerFile, timestamp, timestamp);
|
|
404
|
+
fs.utimesSync(lockDir, timestamp, timestamp);
|
|
405
|
+
} catch { /* release, reclamation, or process teardown owns cleanup */ }
|
|
406
|
+
}, heartbeatMs);
|
|
370
407
|
return Promise.resolve(result).finally(release) as T;
|
|
371
408
|
}
|
|
372
409
|
release();
|
|
373
410
|
return result;
|
|
374
411
|
}
|
|
375
412
|
|
|
413
|
+
function readSubjectLockOwner(file: string): { token?: string } | null {
|
|
414
|
+
try {
|
|
415
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
416
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
417
|
+
? value as { token?: string }
|
|
418
|
+
: null;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
376
425
|
/**
|
|
377
426
|
* The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
|
|
378
427
|
* §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
BUILDER_BUILD_FLOW_ID,
|
|
18
18
|
evaluateBuilderBuildRun,
|
|
19
19
|
startBuilderBuildRun,
|
|
20
|
-
} from "../../build/src/
|
|
20
|
+
} from "../../build/src/builder-flow-run-adapter.js";
|
|
21
21
|
|
|
22
22
|
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
|
23
23
|
const BUILDER_BUILD_DEFINITION = path.join(REPO_ROOT, "kits/builder/flows/build.flow.json");
|
|
@@ -245,6 +245,8 @@ test("all verified parent selectors advance only their persisted gates through p
|
|
|
245
245
|
subjectType: "flow-step",
|
|
246
246
|
name: "verify-all-selectors",
|
|
247
247
|
bundle: trustBundle({ claims: [
|
|
248
|
+
claim("workflow.critique.review", "workflow-critique"),
|
|
249
|
+
claim("workflow.acceptance.criterion", "flow-step"),
|
|
248
250
|
claim("builder.verify.tests", "flow-step"),
|
|
249
251
|
claim("builder.verify.policy-compliance", "artifact"),
|
|
250
252
|
] }),
|
|
@@ -271,7 +273,7 @@ test("all verified parent selectors advance only their persisted gates through p
|
|
|
271
273
|
{ gate_id: "design-probe-gate", status: "pass", matched_expectations: ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"] },
|
|
272
274
|
{ gate_id: "plan-gate", status: "pass", matched_expectations: ["implementation-plan"] },
|
|
273
275
|
{ gate_id: "execute-gate", status: "pass", matched_expectations: ["implementation-scope"] },
|
|
274
|
-
{ gate_id: "verify-gate", status: "pass", matched_expectations: ["tests-evidence", "policy-compliance"] },
|
|
276
|
+
{ gate_id: "verify-gate", status: "pass", matched_expectations: ["clean-critique", "acceptance-criteria", "tests-evidence", "policy-compliance"] },
|
|
275
277
|
{ gate_id: "merge-ready-gate", status: "pass", matched_expectations: ["merge-readiness"] },
|
|
276
278
|
]);
|
|
277
279
|
});
|
|
@@ -3,12 +3,13 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { generateKeyPairSync, sign } from "node:crypto";
|
|
6
|
+
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
7
7
|
|
|
8
8
|
import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
|
|
9
9
|
import {
|
|
10
10
|
archiveBuilderFlowSession,
|
|
11
11
|
cancelBuilderFlowSession,
|
|
12
|
+
captureReviewWorkspaceSnapshot,
|
|
12
13
|
pauseBuilderFlowSession,
|
|
13
14
|
recoverBuilderFlowSession,
|
|
14
15
|
releaseBuilderFlowAssignment,
|
|
@@ -20,6 +21,7 @@ import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization
|
|
|
20
21
|
import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
|
|
21
22
|
import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
|
|
22
23
|
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
24
|
+
import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
|
|
23
25
|
|
|
24
26
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
25
27
|
const NOW = "2026-07-09T20:00:00.000Z";
|
|
@@ -51,6 +53,9 @@ function makeSession(slug = "runtime-projection") {
|
|
|
51
53
|
schema_version: "1.0",
|
|
52
54
|
keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
|
|
53
55
|
});
|
|
56
|
+
fs.mkdirSync(path.join(projectRoot, "review-target"), { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(projectRoot, "review-target", "implementation.txt"), "reviewed implementation\n");
|
|
58
|
+
fs.writeFileSync(path.join(projectRoot, "review-target", "delivery.md"), "reviewed delivery artifact\n");
|
|
54
59
|
return { projectRoot, artifactRoot, sessionDir, slug };
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -59,6 +64,15 @@ function writeJson(file, value) {
|
|
|
59
64
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
test("shell output cannot spoof an executed-test count", () => {
|
|
68
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-test-count-"));
|
|
69
|
+
fs.mkdirSync(path.join(root, "checks"), { recursive: true });
|
|
70
|
+
fs.writeFileSync(path.join(root, "checks", "fake-test.sh"), "#!/bin/sh\nset -e\nprintf '1 passed\\n'\n");
|
|
71
|
+
fs.writeFileSync(path.join(root, "checks", "real-test.sh"), "#!/bin/sh\nset -e\ntest -f checks/real-test.sh\n");
|
|
72
|
+
assert.equal(inferExecutedTestCount("sh checks/fake-test.sh", root, "1 passed\n"), 0);
|
|
73
|
+
assert.equal(inferExecutedTestCount("sh checks/real-test.sh", root, "1..1\nok 1 - file exists\n"), 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
62
76
|
function readJson(file) {
|
|
63
77
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
64
78
|
}
|
|
@@ -200,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
|
|
|
200
214
|
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
201
215
|
}
|
|
202
216
|
|
|
203
|
-
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
|
|
217
|
+
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = NOW }) {
|
|
204
218
|
const claimId = `claim.${expectation}`;
|
|
205
219
|
return {
|
|
206
220
|
claim: {
|
|
@@ -212,6 +226,11 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
212
226
|
value: status,
|
|
213
227
|
metadata: {
|
|
214
228
|
workflow_subject_ref: subject,
|
|
229
|
+
...(expectation === "tests-evidence" ? {
|
|
230
|
+
recorded_by: "implementation-actor",
|
|
231
|
+
artifact_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }],
|
|
232
|
+
observed_commands: [{ command: "node --test src/cli/builder-flow-runtime.test.mjs", exit_code: 0, test_count: testCount, output_sha256: "0".repeat(64) }],
|
|
233
|
+
} : {}),
|
|
215
234
|
gate_claim: {
|
|
216
235
|
expectation_id: expectation,
|
|
217
236
|
claim_type: claimType,
|
|
@@ -219,8 +238,8 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
219
238
|
...(routeReason ? { route_reason: routeReason } : {}),
|
|
220
239
|
},
|
|
221
240
|
},
|
|
222
|
-
createdAt:
|
|
223
|
-
updatedAt:
|
|
241
|
+
createdAt: timestamp,
|
|
242
|
+
updatedAt: timestamp,
|
|
224
243
|
},
|
|
225
244
|
evidence: {
|
|
226
245
|
id: `evidence.${expectation}`,
|
|
@@ -229,7 +248,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
229
248
|
method: "attestation",
|
|
230
249
|
sourceRef: "src/cli/builder-flow-runtime.test.mjs",
|
|
231
250
|
excerptOrSummary: `${expectation} fixture`,
|
|
232
|
-
observedAt:
|
|
251
|
+
observedAt: timestamp,
|
|
233
252
|
collectedBy: "flow-agents-test",
|
|
234
253
|
},
|
|
235
254
|
event: {
|
|
@@ -239,10 +258,47 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
|
|
|
239
258
|
actor: "flow-agents-test",
|
|
240
259
|
method: "attestation",
|
|
241
260
|
evidenceIds: [`evidence.${expectation}`],
|
|
242
|
-
createdAt:
|
|
243
|
-
verifiedAt:
|
|
261
|
+
createdAt: timestamp,
|
|
262
|
+
verifiedAt: timestamp,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function verifiedTestsPrerequisites(session, timestamp = NOW) {
|
|
268
|
+
const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
|
|
269
|
+
const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
|
|
270
|
+
const implementationFile = path.relative(session.projectRoot, implementation);
|
|
271
|
+
const implementationBytes = fs.readFileSync(implementation);
|
|
272
|
+
const implementationSha256 = createHash("sha256").update(implementationBytes).digest("hex");
|
|
273
|
+
const workspaceDigest = createHash("sha256")
|
|
274
|
+
.update("flow-agents:reviewed-files:v1\0")
|
|
275
|
+
.update(implementationFile).update("\0").update(implementationBytes).update("\0")
|
|
276
|
+
.digest("hex");
|
|
277
|
+
const critique = bundleClaim({ expectation: "clean-critique", claimType: "workflow.critique.review", subjectType: "workflow-critique", timestamp });
|
|
278
|
+
critique.claim.metadata = {
|
|
279
|
+
workflow_subject_ref: SUBJECT,
|
|
280
|
+
origin: "critique",
|
|
281
|
+
reviewer: "reviewer-actor",
|
|
282
|
+
findings: [],
|
|
283
|
+
lanes: [{ id: "code", status: "pass" }],
|
|
284
|
+
review_target: {
|
|
285
|
+
artifacts: [{ file: path.relative(session.projectRoot, reviewArtifact), sha256: createHash("sha256").update(fs.readFileSync(reviewArtifact)).digest("hex") }],
|
|
286
|
+
workspace_snapshot: {
|
|
287
|
+
version: 1,
|
|
288
|
+
kind: "reviewed-files",
|
|
289
|
+
algorithm: "sha256",
|
|
290
|
+
digest: workspaceDigest,
|
|
291
|
+
files: [{ file: implementationFile, sha256: implementationSha256 }],
|
|
292
|
+
},
|
|
244
293
|
},
|
|
245
294
|
};
|
|
295
|
+
const criterion = bundleClaim({ expectation: "verified-criterion", claimType: "workflow.acceptance.criterion", subjectType: "flow-step", timestamp });
|
|
296
|
+
criterion.claim.metadata = {
|
|
297
|
+
workflow_subject_ref: SUBJECT,
|
|
298
|
+
origin: "acceptance",
|
|
299
|
+
criterion: { id: "ac-runtime", status: "pass", evidence_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }] },
|
|
300
|
+
};
|
|
301
|
+
return [critique, criterion];
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
function writeBundle(sessionDir, entries) {
|
|
@@ -291,6 +347,53 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
291
347
|
assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
|
|
292
348
|
});
|
|
293
349
|
|
|
350
|
+
test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
|
|
351
|
+
const session = makeSession("snapshot-attachment");
|
|
352
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
353
|
+
const originalEntries = [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })];
|
|
354
|
+
writeBundle(session.sessionDir, originalEntries);
|
|
355
|
+
const bundleFile = path.join(session.sessionDir, "trust.bundle");
|
|
356
|
+
const originalDigest = createHash("sha256").update(fs.readFileSync(bundleFile)).digest("hex");
|
|
357
|
+
let replaceBundle;
|
|
358
|
+
const replaced = new Promise((resolve) => { replaceBundle = resolve; });
|
|
359
|
+
const watcher = fs.watch(session.sessionDir, (_event, filename) => {
|
|
360
|
+
if (String(filename) !== ".trust-bundle-snapshots") return;
|
|
361
|
+
fs.writeFileSync(bundleFile, "{\"claims\":[]}");
|
|
362
|
+
replaceBundle();
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
const syncing = syncBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
366
|
+
await Promise.race([
|
|
367
|
+
replaced,
|
|
368
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("trust.bundle snapshot was not staged")), 2_000)),
|
|
369
|
+
]);
|
|
370
|
+
const synced = await syncing;
|
|
371
|
+
assert.equal(synced.attached, true);
|
|
372
|
+
} finally {
|
|
373
|
+
watcher.close();
|
|
374
|
+
}
|
|
375
|
+
const manifest = readJson(path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH));
|
|
376
|
+
assert.equal(manifest.evidence.at(-1).sha256, originalDigest);
|
|
377
|
+
assert.equal(fs.existsSync(path.join(session.sessionDir, ".trust-bundle-snapshots")), false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("start rejects a requested Builder flow that differs from the existing run before projection mutation", async (t) => {
|
|
381
|
+
for (const [existingFlowId, requestedFlowId] of [["builder.shape", "builder.build"], ["builder.build", "builder.shape"]]) {
|
|
382
|
+
await t.test(`${existingFlowId} cannot resume as ${requestedFlowId}`, async () => {
|
|
383
|
+
const session = makeSession(`flow-mismatch-${existingFlowId.replace('.', '-')}`);
|
|
384
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: existingFlowId });
|
|
385
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
386
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
387
|
+
await assert.rejects(
|
|
388
|
+
() => startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: requestedFlowId }),
|
|
389
|
+
new RegExp(`requested ${requestedFlowId} does not match the existing ${existingFlowId} run`),
|
|
390
|
+
);
|
|
391
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
392
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
294
397
|
test("pause and resume preserve the current Flow step and active assignment", async () => {
|
|
295
398
|
const session = makeSession("lifecycle-pause-resume");
|
|
296
399
|
claimAmbientSessionAssignment(session);
|
|
@@ -546,6 +649,8 @@ test("builder-run exposes lifecycle actions without caller-selected Flow identit
|
|
|
546
649
|
const ambient = resolveCurrentAssignmentActor();
|
|
547
650
|
performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
|
|
548
651
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
652
|
+
assert.equal(await builderRunMain(["start", "--session-dir", session.sessionDir]), 64);
|
|
653
|
+
assert.equal(await builderRunMain(["sync", "--session-dir", session.sessionDir]), 64);
|
|
549
654
|
assert.equal(await builderRunMain([
|
|
550
655
|
"pause", "--session-dir", session.sessionDir,
|
|
551
656
|
"--reason", "cli pause",
|
|
@@ -644,6 +749,87 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
|
|
|
644
749
|
assert.equal(routed.projection.flow_run.route_back_max_attempts, 3);
|
|
645
750
|
assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
|
|
646
751
|
assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
|
|
752
|
+
|
|
753
|
+
const reentered = await writeAndSync(session, [bundleClaim({
|
|
754
|
+
expectation: "implementation-scope",
|
|
755
|
+
claimType: "builder.execute.scope",
|
|
756
|
+
subjectType: "change",
|
|
757
|
+
timestamp: new Date().toISOString(),
|
|
758
|
+
})]);
|
|
759
|
+
assert.equal(reentered.run.state.current_step, "verify");
|
|
760
|
+
const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
|
|
761
|
+
const corrected = await writeAndSync(session, [
|
|
762
|
+
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
|
|
763
|
+
...verifiedTestsPrerequisites(session, correctedAt),
|
|
764
|
+
]);
|
|
765
|
+
assert.equal(corrected.run.state.current_step, "merge-ready");
|
|
766
|
+
const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
|
|
767
|
+
assert.equal(verifyEvidence.length, 2);
|
|
768
|
+
assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
|
|
772
|
+
const session = makeSession("stale-critique-artifact");
|
|
773
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
774
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
775
|
+
await writeAndSync(session, [
|
|
776
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
777
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
778
|
+
]);
|
|
779
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
780
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
781
|
+
const prerequisites = verifiedTestsPrerequisites(session);
|
|
782
|
+
fs.writeFileSync(path.join(session.projectRoot, "review-target", "delivery.md"), "changed after review\n");
|
|
783
|
+
await assert.rejects(
|
|
784
|
+
() => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
|
|
785
|
+
/review_target\.artifacts\.sha256.*does not match/,
|
|
786
|
+
);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("passing tests-evidence rejects a successful command that executed zero tests", async () => {
|
|
790
|
+
const session = makeSession("zero-test-evidence");
|
|
791
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
792
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
793
|
+
await writeAndSync(session, [
|
|
794
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
795
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
796
|
+
]);
|
|
797
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
798
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
799
|
+
await assert.rejects(
|
|
800
|
+
() => writeAndSync(session, [
|
|
801
|
+
bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", testCount: 0 }),
|
|
802
|
+
...verifiedTestsPrerequisites(session),
|
|
803
|
+
]),
|
|
804
|
+
/positive executed-test count/,
|
|
805
|
+
);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("passing tests-evidence rejects a critique after implementation source changed", async () => {
|
|
809
|
+
const session = makeSession("stale-critique-workspace");
|
|
810
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
811
|
+
await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
|
|
812
|
+
await writeAndSync(session, [
|
|
813
|
+
bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
|
|
814
|
+
bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
|
|
815
|
+
]);
|
|
816
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
|
|
817
|
+
await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
|
|
818
|
+
const prerequisites = verifiedTestsPrerequisites(session);
|
|
819
|
+
fs.writeFileSync(path.join(session.projectRoot, "review-target", "implementation.txt"), "source changed after review\n");
|
|
820
|
+
await assert.rejects(
|
|
821
|
+
() => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
|
|
822
|
+
/review_target\.workspace_snapshot\.digest.*does not match/,
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("workspace review fails closed when a Git worktree marker cannot be inspected", () => {
|
|
827
|
+
const session = makeSession("git-inspection-unavailable");
|
|
828
|
+
fs.mkdirSync(path.join(session.projectRoot, ".git"));
|
|
829
|
+
assert.throws(
|
|
830
|
+
() => captureReviewWorkspaceSnapshot(session.projectRoot, [{ file: "review-target/delivery.md", sha256: createHash("sha256").update("reviewed delivery\n").digest("hex") }]),
|
|
831
|
+
/could not inspect the Git worktree/,
|
|
832
|
+
);
|
|
647
833
|
});
|
|
648
834
|
|
|
649
835
|
test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
|
|
@@ -658,7 +844,7 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
658
844
|
],
|
|
659
845
|
[bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
|
|
660
846
|
[bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
|
|
661
|
-
[bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" })],
|
|
847
|
+
[bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
|
|
662
848
|
[bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
|
|
663
849
|
];
|
|
664
850
|
for (const entries of steps) await writeAndSync(session, entries);
|
package/src/cli/builder-run.ts
CHANGED
|
@@ -6,11 +6,9 @@ import {
|
|
|
6
6
|
recoverBuilderFlowSession,
|
|
7
7
|
releaseBuilderFlowAssignment,
|
|
8
8
|
resumeBuilderFlowSession,
|
|
9
|
-
startBuilderFlowSession,
|
|
10
|
-
syncBuilderFlowSession,
|
|
11
9
|
} from "../builder-flow-runtime.js";
|
|
12
10
|
|
|
13
|
-
const USAGE = "Usage: flow-agents builder-run <
|
|
11
|
+
const USAGE = "Usage: flow-agents builder-run <recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
|
|
14
12
|
|
|
15
13
|
export async function main(argv: string[]): Promise<number> {
|
|
16
14
|
const parsed = parseArgs(argv);
|
|
@@ -29,7 +27,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
29
27
|
console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
|
|
30
28
|
return 64;
|
|
31
29
|
}
|
|
32
|
-
if (!action || !["
|
|
30
|
+
if (!action || !["recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
|
|
33
31
|
console.error(USAGE);
|
|
34
32
|
return 64;
|
|
35
33
|
}
|
|
@@ -53,11 +51,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
53
51
|
console.error(USAGE);
|
|
54
52
|
return 64;
|
|
55
53
|
}
|
|
56
|
-
const result = action === "
|
|
57
|
-
? await startBuilderFlowSession({ sessionDir })
|
|
58
|
-
: action === "sync"
|
|
59
|
-
? await syncBuilderFlowSession({ sessionDir })
|
|
60
|
-
: action === "recover"
|
|
54
|
+
const result = action === "recover"
|
|
61
55
|
? await recoverBuilderFlowSession({ sessionDir })
|
|
62
56
|
: action === "pause"
|
|
63
57
|
? await pauseBuilderFlowSession({ sessionDir, reason: reason! })
|