@kontourai/flow-agents 3.4.3 → 3.6.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.
Files changed (65) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +10 -1
  4. package/build/src/builder-flow-run-adapter.js +29 -1
  5. package/build/src/builder-flow-runtime.d.ts +18 -0
  6. package/build/src/builder-flow-runtime.js +205 -13
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +13 -0
  10. package/build/src/cli/assignment-provider.js +120 -62
  11. package/build/src/cli/builder-run.js +46 -5
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +3 -0
  14. package/build/src/cli/workflow-sidecar.js +140 -30
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +521 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/index.d.ts +4 -0
  19. package/build/src/index.js +2 -0
  20. package/build/src/lib/flow-resolver.js +7 -2
  21. package/build/src/lib/package-version.d.ts +2 -0
  22. package/build/src/lib/package-version.js +13 -0
  23. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  24. package/build/src/lib/pinned-cli-command.js +21 -0
  25. package/context/contracts/artifact-contract.md +1 -1
  26. package/context/contracts/assignment-provider-contract.md +5 -2
  27. package/context/scripts/hooks/config-protection.js +8 -1
  28. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  29. package/context/scripts/hooks/stop-goal-fit.js +1 -1
  30. package/docs/context-map.md +2 -0
  31. package/docs/public-workflow-cli.md +63 -0
  32. package/docs/spec/builder-flow-runtime.md +49 -5
  33. package/docs/workflow-usage-guide.md +5 -0
  34. package/evals/ci/run-baseline.sh +2 -0
  35. package/evals/integration/test_assignment_provider_local_file.sh +54 -0
  36. package/evals/integration/test_builder_entry_enforcement.sh +241 -24
  37. package/evals/integration/test_bundle_install.sh +97 -0
  38. package/evals/integration/test_current_json_per_actor.sh +1 -0
  39. package/evals/integration/test_dual_emit_flow_step.sh +2 -0
  40. package/evals/integration/test_flowdef_session_activation.sh +6 -3
  41. package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +4 -0
  43. package/evals/integration/test_public_workflow_cli.sh +259 -0
  44. package/evals/integration/test_workflow_sidecar_writer.sh +1 -0
  45. package/package.json +2 -2
  46. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  47. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  48. package/schemas/workflow-state.schema.json +1 -1
  49. package/scripts/hooks/config-protection.js +8 -1
  50. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  51. package/scripts/hooks/stop-goal-fit.js +1 -1
  52. package/src/builder-flow-run-adapter.ts +47 -0
  53. package/src/builder-flow-runtime.ts +216 -4
  54. package/src/builder-lifecycle-authority.ts +218 -0
  55. package/src/cli/assignment-provider.ts +84 -20
  56. package/src/cli/builder-flow-runtime.test.mjs +404 -1
  57. package/src/cli/builder-run.ts +56 -5
  58. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  59. package/src/cli/workflow-sidecar.ts +138 -31
  60. package/src/cli/workflow.ts +471 -0
  61. package/src/cli.ts +2 -0
  62. package/src/index.ts +14 -0
  63. package/src/lib/flow-resolver.ts +6 -2
  64. package/src/lib/package-version.ts +15 -0
  65. package/src/lib/pinned-cli-command.ts +23 -0
@@ -31,6 +31,7 @@ export type AssignmentClaimRecord = {
31
31
  subject_id: string;
32
32
  actor: ActorStruct;
33
33
  actor_key?: string;
34
+ work_item_ref?: string;
34
35
  claimed_at: string;
35
36
  ttl_seconds: number;
36
37
  branch: string;
@@ -53,6 +54,10 @@ export type AssignmentStatus = {
53
54
  record: AssignmentClaimRecord | null;
54
55
  has_claim_label?: boolean;
55
56
  };
57
+ export declare function resolveCurrentAssignmentActor(): {
58
+ actor: ActorStruct;
59
+ actorKey: string;
60
+ };
56
61
  export declare function assignmentFilePath(artifactRoot: string, subjectId: string): string;
57
62
  export declare function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null;
58
63
  export declare function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void;
@@ -116,6 +121,7 @@ export declare function performLocalClaim(artifactRoot: string, subjectId: strin
116
121
  artifactDir: string;
117
122
  reason?: string;
118
123
  actorKey?: string;
124
+ workItemRef?: string;
119
125
  }): AssignmentClaimRecord;
120
126
  /**
121
127
  * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
@@ -162,6 +168,12 @@ export declare function performLocalRelease(artifactRoot: string, subjectId: str
162
168
  actorKey?: string;
163
169
  tolerateNoActiveClaim?: boolean;
164
170
  }): AssignmentClaimRecord | null;
171
+ /** Caller must already hold this subject's assignment lock through withSubjectLock(). */
172
+ export declare function performLocalReleaseUnderLock(artifactRoot: string, subjectId: string, releasedBy: ActorStruct | null, opts?: {
173
+ reason?: string;
174
+ actorKey?: string;
175
+ tolerateNoActiveClaim?: boolean;
176
+ }): AssignmentClaimRecord | null;
165
177
  /**
166
178
  * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
167
179
  * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
@@ -177,6 +189,7 @@ export declare function performLocalSupersede(artifactRoot: string, subjectId: s
177
189
  artifactDir?: string;
178
190
  reason?: string;
179
191
  actorKey?: string;
192
+ workItemRef?: string;
180
193
  }): AssignmentClaimRecord;
181
194
  /**
182
195
  * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs, flagString } from "../lib/args.js";
7
- import { readJson, writeJson, isoNow } from "../lib/fs.js";
7
+ import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
8
8
  const DEFAULT_LABEL_NAME = "agent:claimed";
9
9
  const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
10
10
  /**
@@ -76,6 +76,9 @@ function loadActorStruct(args) {
76
76
  const actorJsonPath = flagString(args.flags, "actor-json");
77
77
  if (actorJsonPath)
78
78
  return { actor: loadActorStructFromFile(actorJsonPath) };
79
+ return resolveCurrentAssignmentActor();
80
+ }
81
+ export function resolveCurrentAssignmentActor() {
79
82
  const helper = loadActorIdentityHelper();
80
83
  const resolved = helper.resolveActor(process.env);
81
84
  if (helper.isUnresolvedActor(resolved.actor))
@@ -86,28 +89,74 @@ function loadActorStruct(args) {
86
89
  // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
87
90
  // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
88
91
  const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
89
- const actor = ci && ci.session_id
90
- ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
91
- : { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
92
+ const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
93
+ const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
94
+ const actor = resolved.source === "explicit-override"
95
+ ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
96
+ : ci && ci.session_id
97
+ ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
98
+ : runtimeSessionId
99
+ ? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
100
+ : { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
92
101
  return { actor, actorKey: resolved.actor };
93
102
  }
94
103
  export function assignmentFilePath(artifactRoot, subjectId) {
95
104
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
96
105
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
97
106
  }
107
+ function localAssignmentDir(artifactRoot, create) {
108
+ const dir = path.join(artifactRoot, "assignment");
109
+ if (create)
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ let stat;
112
+ try {
113
+ stat = fs.lstatSync(dir);
114
+ }
115
+ catch (error) {
116
+ if (!create && error.code === "ENOENT")
117
+ return null;
118
+ throw error;
119
+ }
120
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
121
+ throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
122
+ }
123
+ const realRoot = fs.realpathSync(artifactRoot);
124
+ if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
125
+ throw new Error(`assignment directory escapes the artifact root: ${dir}`);
126
+ }
127
+ return dir;
128
+ }
98
129
  export function readLocalRecord(artifactRoot, subjectId) {
99
- const file = assignmentFilePath(artifactRoot, subjectId);
100
- if (!fs.existsSync(file))
130
+ if (!localAssignmentDir(artifactRoot, false))
101
131
  return null;
132
+ const file = assignmentFilePath(artifactRoot, subjectId);
133
+ let stat;
134
+ try {
135
+ stat = fs.lstatSync(file);
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return null;
140
+ throw error;
141
+ }
142
+ if (stat.isSymbolicLink() || !stat.isFile()) {
143
+ throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
144
+ }
102
145
  let data;
146
+ let descriptor = null;
103
147
  try {
104
- data = readJson(file);
148
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
149
+ data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
105
150
  }
106
151
  catch (error) {
107
152
  // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
108
153
  // would be a fail-open path that could let a second claim silently overwrite a real one.
109
154
  throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${error.message}`);
110
155
  }
156
+ finally {
157
+ if (descriptor !== null)
158
+ fs.closeSync(descriptor);
159
+ }
111
160
  if (typeof data !== "object" || data === null)
112
161
  throw new Error(`assignment record is not an object: ${file}`);
113
162
  const record = data;
@@ -119,7 +168,7 @@ export function writeLocalRecord(artifactRoot, subjectId, record) {
119
168
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
120
169
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
121
170
  // never fail open (artifact-contract.md).
122
- writeJson(assignmentFilePath(artifactRoot, subjectId), record);
171
+ atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
123
172
  }
124
173
  /**
125
174
  * Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
@@ -133,8 +182,7 @@ function sleepSync(ms) {
133
182
  Atomics.wait(ia, 0, 0, ms);
134
183
  }
135
184
  function subjectLockDir(artifactRoot, subjectId) {
136
- const assignmentDir = path.join(artifactRoot, "assignment");
137
- fs.mkdirSync(assignmentDir, { recursive: true });
185
+ const assignmentDir = localAssignmentDir(artifactRoot, true);
138
186
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
139
187
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
140
188
  }
@@ -190,12 +238,20 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
190
238
  sleepSync(20);
191
239
  }
192
240
  }
241
+ const release = () => fs.rmSync(lockDir, { recursive: true, force: true });
242
+ let result;
193
243
  try {
194
- return body();
244
+ result = body();
195
245
  }
196
- finally {
197
- fs.rmSync(lockDir, { recursive: true, force: true });
246
+ catch (error) {
247
+ release();
248
+ throw error;
249
+ }
250
+ if (result && typeof result.then === "function") {
251
+ return Promise.resolve(result).finally(release);
198
252
  }
253
+ release();
254
+ return result;
199
255
  }
200
256
  /**
201
257
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
@@ -403,6 +459,7 @@ export function performLocalClaim(artifactRoot, subjectId, actor, opts) {
403
459
  subject_id: subjectId,
404
460
  actor,
405
461
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
462
+ ...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
406
463
  claimed_at: isoNow(),
407
464
  ttl_seconds: opts.ttlSeconds,
408
465
  branch: opts.branch,
@@ -474,61 +531,61 @@ function claimLocalFile(argv) {
474
531
  * than allowed to silently no-op or wrongly refuse.
475
532
  */
476
533
  export function performLocalRelease(artifactRoot, subjectId, releasedBy, opts = {}) {
534
+ return withSubjectLock(artifactRoot, subjectId, () => performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts));
535
+ }
536
+ /** Caller must already hold this subject's assignment lock through withSubjectLock(). */
537
+ export function performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts = {}) {
477
538
  const helper = loadActorIdentityHelper();
478
539
  const reason = opts.reason ?? "released";
479
540
  const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
480
- // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
481
- // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
482
- return withSubjectLock(artifactRoot, subjectId, () => {
483
- const existing = readLocalRecord(artifactRoot, subjectId);
484
- if (!existing || existing.status !== "claimed") {
485
- if (tolerateNoActiveClaim)
541
+ const existing = readLocalRecord(artifactRoot, subjectId);
542
+ if (!existing || existing.status !== "claimed") {
543
+ if (tolerateNoActiveClaim)
544
+ return null;
545
+ throw new Error(`no active claim to release for subject: ${subjectId}`);
546
+ }
547
+ if (releasedBy) {
548
+ // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
549
+ // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
550
+ // ownership — see this function's doc comment. This is the ONLY combination that fires: it
551
+ // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
552
+ // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
553
+ // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
554
+ if (!opts.actorKey && existing.actor_key) {
555
+ if (tolerateNoActiveClaim) {
556
+ console.error(`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`);
486
557
  return null;
487
- throw new Error(`no active claim to release for subject: ${subjectId}`);
488
- }
489
- if (releasedBy) {
490
- // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
491
- // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
492
- // ownership — see this function's doc comment. This is the ONLY combination that fires: it
493
- // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
494
- // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
495
- // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
496
- if (!opts.actorKey && existing.actor_key) {
497
- if (tolerateNoActiveClaim) {
498
- console.error(`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`);
499
- return null;
500
- }
501
- throw new Error("performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records");
502
- }
503
- // AC6: never force-release a claim held by a different actor. Mirrors
504
- // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
505
- // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
506
- // string, present on records written by the fixed performLocalClaim/performLocalSupersede
507
- // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
508
- // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
509
- // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
510
- // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
511
- // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
512
- // would compare the bare actor_key form against a re-derived triple form for an
513
- // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
514
- // seam, relocated to this write path).
515
- const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
516
- const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
517
- if (holderActorKey !== releasedByActorKey) {
518
- if (tolerateNoActiveClaim)
519
- return null;
520
- throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
521
558
  }
559
+ throw new Error("performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records");
522
560
  }
523
- const record = {
524
- ...existing,
525
- ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
526
- status: "released",
527
- audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
528
- };
529
- writeLocalRecord(artifactRoot, subjectId, record);
530
- return record;
531
- });
561
+ // AC6: never force-release a claim held by a different actor. Mirrors
562
+ // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
563
+ // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
564
+ // string, present on records written by the fixed performLocalClaim/performLocalSupersede
565
+ // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
566
+ // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
567
+ // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
568
+ // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
569
+ // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
570
+ // would compare the bare actor_key form against a re-derived triple form for an
571
+ // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
572
+ // seam, relocated to this write path).
573
+ const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
574
+ const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
575
+ if (holderActorKey !== releasedByActorKey) {
576
+ if (tolerateNoActiveClaim)
577
+ return null;
578
+ throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
579
+ }
580
+ }
581
+ const record = {
582
+ ...existing,
583
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
584
+ status: "released",
585
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
586
+ };
587
+ writeLocalRecord(artifactRoot, subjectId, record);
588
+ return record;
532
589
  }
533
590
  function releaseLocalFile(argv) {
534
591
  const args = parseArgs(argv);
@@ -574,6 +631,7 @@ export function performLocalSupersede(artifactRoot, subjectId, fromActor, toActo
574
631
  subject_id: subjectId,
575
632
  actor: toActor,
576
633
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
634
+ ...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
577
635
  claimed_at: isoNow(),
578
636
  ttl_seconds: ttlSeconds,
579
637
  branch: opts.branch ?? existing.branch,
@@ -1,35 +1,76 @@
1
1
  import { flagString, parseArgs } from "../lib/args.js";
2
- import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
2
+ import { cancelBuilderFlowSession, archiveBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession, } from "../builder-flow-runtime.js";
3
+ const USAGE = "Usage: flow-agents builder-run <start|sync|recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
3
4
  export async function main(argv) {
4
5
  const parsed = parseArgs(argv);
5
6
  const action = parsed.positionals[0];
6
7
  const sessionDir = flagString(parsed.flags, "session-dir");
8
+ const authorizationFile = flagString(parsed.flags, "authorization-file");
9
+ const reason = flagString(parsed.flags, "reason");
7
10
  const validRecoveryArguments = parsed.positionals.length === 1
8
11
  && Object.keys(parsed.flags).length === 1
9
12
  && typeof parsed.flags["session-dir"] === "string";
10
13
  if (action === "recover" && !validRecoveryArguments) {
11
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
14
+ console.error(USAGE);
12
15
  return 64;
13
16
  }
14
17
  if (!sessionDir) {
15
18
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
16
19
  return 64;
17
20
  }
18
- if (action !== "start" && action !== "sync" && action !== "recover") {
19
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
21
+ if (!action || !["start", "sync", "recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
22
+ console.error(USAGE);
23
+ return 64;
24
+ }
25
+ const agentLifecycle = action === "pause" || action === "resume" || action === "release-assignment";
26
+ const authorizedLifecycle = action === "cancel" || action === "archive";
27
+ const lifecycle = agentLifecycle || authorizedLifecycle;
28
+ const allowedLifecycleFlag = (name) => name === "session-dir" || (agentLifecycle ? name === "reason" : name === "authorization-file");
29
+ if (lifecycle && (parsed.positionals.length !== 1 || Object.keys(parsed.flags).some((name) => !allowedLifecycleFlag(name)))) {
30
+ console.error(USAGE);
31
+ return 64;
32
+ }
33
+ if (agentLifecycle && !reason) {
34
+ console.error(`builder-run ${action} requires --reason <text>`);
35
+ return 64;
36
+ }
37
+ if (authorizedLifecycle && !authorizationFile) {
38
+ console.error(`builder-run ${action} requires a signed --authorization-file <path>`);
39
+ return 64;
40
+ }
41
+ if (!lifecycle && (authorizationFile || reason)) {
42
+ console.error(USAGE);
20
43
  return 64;
21
44
  }
22
45
  const result = action === "start"
23
46
  ? await startBuilderFlowSession({ sessionDir })
24
47
  : action === "sync"
25
48
  ? await syncBuilderFlowSession({ sessionDir })
26
- : await recoverBuilderFlowSession({ sessionDir });
49
+ : action === "recover"
50
+ ? await recoverBuilderFlowSession({ sessionDir })
51
+ : action === "pause"
52
+ ? await pauseBuilderFlowSession({ sessionDir, reason: reason })
53
+ : action === "resume"
54
+ ? await resumeBuilderFlowSession({ sessionDir, reason: reason })
55
+ : action === "cancel"
56
+ ? await cancelBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile })
57
+ : action === "release-assignment"
58
+ ? await releaseBuilderFlowAssignment({ sessionDir, reason: reason })
59
+ : await archiveBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile });
27
60
  console.log(JSON.stringify({
28
61
  run_id: result.run.runId,
29
62
  definition_id: result.run.definitionId,
30
63
  current_step: result.run.state.current_step,
31
64
  status: result.run.state.status,
32
65
  attached: result.attached,
66
+ ...(action === "cancel" ? {
67
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
68
+ idempotent: "idempotent" in result ? result.idempotent : false,
69
+ } : action === "release-assignment" ? {
70
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
71
+ } : action === "archive" ? {
72
+ archive_dir: "archiveDir" in result ? result.archiveDir : null,
73
+ } : {}),
33
74
  next_action: result.projection.next_action,
34
75
  }));
35
76
  return 0;
@@ -255,6 +255,9 @@ function classifyWorkflow(slug, workflowPath) {
255
255
  if (status === "verified" && nextStatus === "done") {
256
256
  return { ...base, classification: "cleanup_candidate", reasons: ["verified workflow has next_action.status done"] };
257
257
  }
258
+ if (status === "canceled" && phase === "done") {
259
+ return { ...base, classification: "terminal_done", reasons: ["canceled workflow retains its artifacts without requiring delivery promotion"] };
260
+ }
258
261
  if (["delivered", "accepted", "archived"].includes(status) && phase === "done") {
259
262
  if (status !== "archived" && !hasPromotionClaim(workflowPath)) {
260
263
  return { ...base, classification: "cleanup_candidate", reasons: [`${status} workflow reached phase done without a promotion claim; ${PROMOTE_REMEDY}`] };
@@ -6,6 +6,7 @@ export declare const phases: string[];
6
6
  export declare const checkKinds: Set<string>;
7
7
  export declare const checkStatuses: Set<string>;
8
8
  export declare const verdicts: Set<string>;
9
+ export declare const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
9
10
  export declare function writeJson(file: string, payload: AnyObj): void;
10
11
  export declare function loadJson(file: string, fallback?: AnyObj): AnyObj;
11
12
  export declare function appendJsonl(file: string, payload: AnyObj): void;
@@ -195,6 +196,7 @@ export declare function writeTrustBundle(dir: string, slug: string, timestamp: s
195
196
  errors: string[];
196
197
  }>;
197
198
  export declare function sidecarBase(slug: string): AnyObj;
199
+ export declare function currentWorkflowSessionDir(root: string): string | null;
198
200
  export declare function validateEvidenceRef(ref: AnyObj, label: string): AnyObj;
199
201
  export declare function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[];
200
202
  export declare function normalizeCheck(raw: AnyObj, allowGateClaimPrefix?: boolean, existingCheckStampById?: ReadonlyMap<string, boolean>): AnyObj;
@@ -622,3 +624,4 @@ export declare function buildGateInquiryRecords(bundle: BundleFile, blockSignal:
622
624
  export declare const LIVENESS_TERMINAL: Set<string>;
623
625
  export { buildClaimExplanation } from "./sidecar-claim-explain.js";
624
626
  export type { ClaimEvidenceItem, ClaimExplanation } from "./sidecar-claim-explain.js";
627
+ export declare function main(argv?: string[]): Promise<number>;