@kontourai/flow-agents 3.5.0 → 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 (55) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +8 -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 +10 -0
  10. package/build/src/cli/assignment-provider.js +61 -52
  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 +28 -6
  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/package-version.d.ts +2 -0
  21. package/build/src/lib/package-version.js +13 -0
  22. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  23. package/build/src/lib/pinned-cli-command.js +21 -0
  24. package/context/contracts/artifact-contract.md +1 -1
  25. package/context/scripts/hooks/config-protection.js +8 -1
  26. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  27. package/context/scripts/hooks/stop-goal-fit.js +1 -1
  28. package/docs/context-map.md +2 -0
  29. package/docs/public-workflow-cli.md +63 -0
  30. package/docs/spec/builder-flow-runtime.md +37 -0
  31. package/docs/workflow-usage-guide.md +5 -0
  32. package/evals/ci/run-baseline.sh +2 -0
  33. package/evals/integration/test_builder_entry_enforcement.sh +5 -4
  34. package/evals/integration/test_bundle_install.sh +59 -5
  35. package/evals/integration/test_public_workflow_cli.sh +259 -0
  36. package/package.json +2 -2
  37. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  38. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  39. package/schemas/workflow-state.schema.json +1 -1
  40. package/scripts/hooks/config-protection.js +8 -1
  41. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  42. package/scripts/hooks/stop-goal-fit.js +1 -1
  43. package/src/builder-flow-run-adapter.ts +47 -0
  44. package/src/builder-flow-runtime.ts +216 -4
  45. package/src/builder-lifecycle-authority.ts +218 -0
  46. package/src/cli/assignment-provider.ts +29 -9
  47. package/src/cli/builder-flow-runtime.test.mjs +404 -1
  48. package/src/cli/builder-run.ts +56 -5
  49. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  50. package/src/cli/workflow-sidecar.ts +28 -6
  51. package/src/cli/workflow.ts +471 -0
  52. package/src/cli.ts +2 -0
  53. package/src/index.ts +14 -0
  54. package/src/lib/package-version.ts +15 -0
  55. package/src/lib/pinned-cli-command.ts +23 -0
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://kontourai.dev/schemas/builder-lifecycle-authorization.schema.json",
4
+ "title": "Builder Lifecycle Authorization",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "operation", "run_id", "subject", "assignment_actor_key", "assignment_actor", "nonce", "expires_at", "request", "signature"],
8
+ "properties": {
9
+ "schema_version": { "const": "1.0" },
10
+ "operation": { "enum": ["cancel", "archive"] },
11
+ "run_id": { "type": "string", "minLength": 1 },
12
+ "subject": { "type": "string", "minLength": 1 },
13
+ "assignment_actor_key": { "type": "string", "minLength": 1 },
14
+ "assignment_actor": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["runtime", "session_id", "host", "human"],
18
+ "properties": {
19
+ "runtime": { "type": "string", "minLength": 1 },
20
+ "session_id": { "type": "string", "minLength": 1 },
21
+ "host": { "type": "string", "minLength": 1 },
22
+ "human": { "type": ["string", "null"] }
23
+ }
24
+ },
25
+ "nonce": { "type": "string", "minLength": 1, "maxLength": 256 },
26
+ "expires_at": { "type": "string", "format": "date-time" },
27
+ "request": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["reason", "authority"],
31
+ "properties": {
32
+ "reason": { "type": "string", "minLength": 1 },
33
+ "authority": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": ["kind", "actor", "request_ref", "requested_at"],
37
+ "properties": {
38
+ "kind": { "enum": ["user_request", "operator_request"] },
39
+ "actor": { "type": "string", "minLength": 1 },
40
+ "request_ref": { "type": "string", "minLength": 1 },
41
+ "requested_at": { "type": "string", "format": "date-time" }
42
+ }
43
+ }
44
+ }
45
+ },
46
+ "signature": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "required": ["algorithm", "key_id", "value"],
50
+ "properties": {
51
+ "algorithm": { "const": "ed25519" },
52
+ "key_id": { "type": "string", "minLength": 1 },
53
+ "value": { "type": "string", "minLength": 1 }
54
+ }
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://kontourai.dev/schemas/lifecycle-authority-keys.schema.json",
4
+ "title": "Lifecycle Authority Key Registry",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "keys"],
8
+ "properties": {
9
+ "schema_version": { "const": "1.0" },
10
+ "keys": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "items": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["id", "algorithm", "public_key_pem"],
17
+ "properties": {
18
+ "id": { "type": "string", "minLength": 1 },
19
+ "algorithm": { "const": "ed25519" },
20
+ "public_key_pem": { "type": "string", "minLength": 1 }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "status": {
29
29
  "type": "string",
30
- "enum": ["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]
30
+ "enum": ["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "canceled", "accepted", "archived"]
31
31
  },
32
32
  "phase": {
33
33
  "type": "string",
@@ -120,6 +120,13 @@ function checkProtectedPathPattern(filePath) {
120
120
  };
121
121
  }
122
122
 
123
+ if (/(?:^|\/)\.flow-agents\/lifecycle-authority-keys\.json$/.test(norm)) {
124
+ return {
125
+ name: '.flow-agents/lifecycle-authority-keys.json',
126
+ reason: 'an agent could replace the pinned lifecycle authority key and forge cancellation authority',
127
+ };
128
+ }
129
+
123
130
  // .kontourai/flow-agents/current.json — an agent could forge active_flow_id / active_step_id
124
131
  // to route the gate to a permissive or empty-expects FlowDefinition.
125
132
  // SAFE: the workflow CLI writes current.json via fs (writeJson → fs.writeFileSync),
@@ -426,7 +433,7 @@ function checkCommandForBypass(command) {
426
433
  */
427
434
  // #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
428
435
  // per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
429
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
436
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)\.flow-agents\/lifecycle-authority-keys\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
430
437
 
431
438
  /**
432
439
  * Return true when a token (an unquoted redirect target or tee argument) matches
@@ -20,6 +20,8 @@ const SANCTIONED_REMEDIES = {
20
20
  'There is no sanctioned automated writer for this file. Ask a human maintainer to edit it directly. Never disable this hook to make the write.',
21
21
  '.claude/settings.local.json':
22
22
  'There is no sanctioned automated writer for this file. Ask a human maintainer to edit it directly. Never disable this hook to make the write.',
23
+ '.flow-agents/lifecycle-authority-keys.json':
24
+ 'Only the trusted harness installer or a human maintainer may rotate lifecycle authority keys. Never let an agent replace this trust root.',
23
25
  '.kontourai/flow-agents/current.json':
24
26
  'Use `npm run workflow:sidecar -- ensure-session` (or `advance-state`), which writes this file for you. Never disable this hook to make the write.',
25
27
  '.kontourai/flow-agents/current/<actor>.json':
@@ -58,6 +60,7 @@ function remedyFor(name) {
58
60
  * basename 'trust.bundle') -- first match wins, deterministically.
59
61
  */
60
62
  const REMEDY_COMMAND_CANDIDATES = [
63
+ { name: '.flow-agents/lifecycle-authority-keys.json', needles: ['lifecycle-authority-keys.json'] },
61
64
  { name: 'delivery/trust.checkpoint.json', needles: ['delivery/trust.checkpoint.json', 'trust.checkpoint.json'] },
62
65
  { name: 'delivery/trust.bundle', needles: ['delivery/trust.bundle'] },
63
66
  { name: '.kontourai/flow-agents/<slug>/trust.bundle', needles: ['trust.bundle'] },
@@ -77,7 +77,7 @@ const PRE_EXECUTION_PHASES = new Set(['idea', 'backlog', 'pickup', 'planning']);
77
77
  // Terminal tasks are complete — they must never gate a stop or count as "active".
78
78
  // A stale current.json pointing at one, or a graveyard of finished states, must
79
79
  // not block an unrelated session.
80
- const TERMINAL_STATUSES = new Set(['done', 'delivered', 'accepted', 'archived', 'complete', 'completed']);
80
+ const TERMINAL_STATUSES = new Set(['done', 'delivered', 'canceled', 'accepted', 'archived', 'complete', 'completed']);
81
81
 
82
82
  function isTerminalDeliveredState(state) {
83
83
  if (!state || typeof state !== 'object') return false;
@@ -5,15 +5,19 @@ import { fileURLToPath } from "node:url";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import {
7
7
  attachEvidence,
8
+ cancelRun,
8
9
  evaluateRun,
9
10
  expectationsForGate,
10
11
  loadRun,
11
12
  normalizeTrustBundle,
12
13
  openGates,
14
+ pauseRun,
13
15
  readJson,
16
+ resumeRun,
14
17
  startRun,
15
18
  validateDefinition,
16
19
  type FlowEvidenceEntry,
20
+ type FlowLifecycleRequest,
17
21
  type FlowRunState,
18
22
  type GateOutcome,
19
23
  type JsonObject,
@@ -67,6 +71,11 @@ export interface LoadBuilderBuildRunInput {
67
71
  cwd?: string;
68
72
  }
69
73
 
74
+ export interface ChangeBuilderBuildRunLifecycleInput extends LoadBuilderBuildRunInput {
75
+ request: FlowLifecycleRequest;
76
+ at?: string;
77
+ }
78
+
70
79
  export interface BuilderBuildRunResult {
71
80
  definitionId: typeof BUILDER_BUILD_FLOW_ID;
72
81
  definitionVersion: string;
@@ -189,6 +198,44 @@ export async function loadBuilderBuildRun(input: LoadBuilderBuildRunInput): Prom
189
198
  return resultFromRun(run, input.runId);
190
199
  }
191
200
 
201
+ export async function pauseBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult> {
202
+ return changeBuilderBuildRunLifecycle(input, pauseRun);
203
+ }
204
+
205
+ export async function resumeBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult> {
206
+ return changeBuilderBuildRunLifecycle(input, resumeRun);
207
+ }
208
+
209
+ export async function cancelBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult & { idempotent: boolean }> {
210
+ const changed = await changeBuilderBuildRunLifecycleResult(input, cancelRun);
211
+ return { ...resultFromRun(changed, input.runId), idempotent: changed.idempotent };
212
+ }
213
+
214
+ async function changeBuilderBuildRunLifecycle(
215
+ input: ChangeBuilderBuildRunLifecycleInput,
216
+ operation: typeof pauseRun | typeof resumeRun,
217
+ ): Promise<BuilderBuildRunResult> {
218
+ const changed = await changeBuilderBuildRunLifecycleResult(input, operation);
219
+ return resultFromRun(changed, input.runId);
220
+ }
221
+
222
+ async function changeBuilderBuildRunLifecycleResult(
223
+ input: ChangeBuilderBuildRunLifecycleInput,
224
+ operation: typeof pauseRun | typeof resumeRun | typeof cancelRun,
225
+ ) {
226
+ assertRuntimeInput(input, []);
227
+ if (!isRecord(input.request)) throw new BuilderBuildRunInputError("request", "must be a lifecycle request object");
228
+ const cwd = input.cwd ?? process.cwd();
229
+ const before = await loadBuilderBuildRun({ runId: input.runId, cwd });
230
+ const changed = await operation(input.runId, { cwd, ...input.request, ...(input.at ? { at: input.at } : {}) });
231
+ const definition = await loadShippedBuilderBuildDefinition(resolveBuilderBuildFlowDefinitionPath());
232
+ assertCanonicalDefinition(input.runId, definition, changed.definition);
233
+ if (changed.state.subject !== before.state.subject) {
234
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "changed during lifecycle transition");
235
+ }
236
+ return changed;
237
+ }
238
+
192
239
  function resultFromRun(run: Awaited<ReturnType<typeof loadRun>>, runId: string): BuilderBuildRunResult {
193
240
  return {
194
241
  definitionId: run.definition.id,
@@ -1,8 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { flowAgentsPackageVersion } from "./lib/package-version.js";
6
+ import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
4
7
  import {
5
8
  expectationsForGate,
9
+ lifecycleRequestMatches,
6
10
  openGates,
7
11
  readJson,
8
12
  runDir,
@@ -12,11 +16,16 @@ import {
12
16
  type FlowRunState,
13
17
  type JsonObject,
14
18
  } from "@kontourai/flow";
19
+ import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
20
+ import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock, type ActorStruct } from "./cli/assignment-provider.js";
15
21
  import {
16
22
  BUILDER_BUILD_FLOW_ID,
17
23
  BuilderBuildRunInputError,
24
+ cancelBuilderBuildRun,
18
25
  evaluateBuilderBuildRun,
19
26
  loadBuilderBuildRun,
27
+ pauseBuilderBuildRun,
28
+ resumeBuilderBuildRun,
20
29
  startBuilderBuildRun,
21
30
  type BuilderBuildRunResult,
22
31
  } from "./builder-flow-run-adapter.js";
@@ -27,6 +36,14 @@ export interface BuilderFlowSessionInput {
27
36
  sessionDir: string;
28
37
  }
29
38
 
39
+ export interface BuilderFlowAuthorizedLifecycleInput extends BuilderFlowSessionInput {
40
+ authorizationFile: string;
41
+ }
42
+
43
+ export interface BuilderFlowAgentLifecycleInput extends BuilderFlowSessionInput {
44
+ reason: string;
45
+ }
46
+
30
47
  export interface BuilderFlowSessionResult {
31
48
  sessionDir: string;
32
49
  projectRoot: string;
@@ -119,6 +136,195 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
119
136
  };
120
137
  }
121
138
 
139
+ export async function pauseBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
140
+ return changeBuilderFlowSessionLifecycle(input, "pause");
141
+ }
142
+
143
+ export async function resumeBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
144
+ return changeBuilderFlowSessionLifecycle(input, "resume");
145
+ }
146
+
147
+ export async function cancelBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & { assignmentReleased: boolean; idempotent: boolean }> {
148
+ const context = resolveSessionContext(input.sessionDir);
149
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
150
+ const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
151
+ assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
152
+ const changed = await cancelBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
153
+ const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
154
+ actorKey: prepared.authorization.assignment_actor_key,
155
+ reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
156
+ tolerateNoActiveClaim: true,
157
+ });
158
+ const projection = projectFlowRun(prepared.context, changed, prepared.sidecarSnapshot.state);
159
+ writeProjection(prepared.context, projection, prepared.sidecarSnapshot.raw, "cancellation projection");
160
+ recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
161
+ return { sessionDir: prepared.context.sessionDir, projectRoot: prepared.context.projectRoot, run: changed, projection, attached: false, assignmentReleased: released !== null, idempotent: changed.idempotent };
162
+ });
163
+ }
164
+
165
+ export async function releaseBuilderFlowAssignment(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult & { assignmentReleased: boolean }> {
166
+ const context = resolveSessionContext(input.sessionDir);
167
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
168
+ const prepared = prepareAgentLifecycleChange(input, context);
169
+ const run = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
170
+ const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
171
+ return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
172
+ });
173
+ }
174
+
175
+ export async function archiveBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & { archiveDir: string }> {
176
+ const context = resolveSessionContext(input.sessionDir);
177
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
178
+ const prepared = await prepareAuthorizedLifecycleChange(input, "archive", context);
179
+ const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
180
+ const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
181
+ if (priorConsumption && !recoveringPreparedArchive) throw new Error("lifecycle authorization nonce has already been consumed");
182
+ const run = await loadBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
183
+ if (run.state.status !== "completed" && run.state.status !== "canceled") {
184
+ throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
185
+ }
186
+ const archiveRoot = path.join(prepared.context.artifactRoot, "archive");
187
+ const archiveDir = path.join(archiveRoot, prepared.context.slug);
188
+ if (pathExistsNoFollow(archiveDir)) throw new BuilderBuildRunInputError("archive", "destination already exists");
189
+ fs.mkdirSync(archiveRoot, { recursive: true });
190
+ assertSafeDirectory(archiveRoot, prepared.context.artifactRoot, "archive root");
191
+ assertSafeDirectory(prepared.context.sessionDir, prepared.context.artifactRoot, "sessionDir");
192
+ if (!recoveringPreparedArchive && fs.readFileSync(prepared.context.stateFile, "utf8") !== prepared.sidecarSnapshot.raw) {
193
+ throw new BuilderBuildRunInputError("state.json", "changed during archive preparation");
194
+ }
195
+ const archivedState = recoveringPreparedArchive ? prepared.sidecarSnapshot.state : {
196
+ ...prepared.sidecarSnapshot.state,
197
+ status: "archived",
198
+ phase: "done",
199
+ updated_at: new Date().toISOString(),
200
+ next_action: { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." },
201
+ };
202
+ if (!recoveringPreparedArchive) {
203
+ writeExistingFileNoFollow(prepared.context.stateFile, `${JSON.stringify(archivedState, null, 2)}\n`);
204
+ clearCurrentPointers(prepared.context.artifactRoot, prepared.context.slug);
205
+ recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
206
+ }
207
+ fs.renameSync(prepared.context.sessionDir, archiveDir);
208
+ return {
209
+ sessionDir: archiveDir,
210
+ projectRoot: prepared.context.projectRoot,
211
+ run,
212
+ projection: archivedState,
213
+ attached: false,
214
+ archiveDir,
215
+ };
216
+ });
217
+ }
218
+
219
+ async function changeBuilderFlowSessionLifecycle(
220
+ input: BuilderFlowAgentLifecycleInput,
221
+ operation: "pause" | "resume",
222
+ ): Promise<BuilderFlowSessionResult> {
223
+ const context = resolveSessionContext(input.sessionDir);
224
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
225
+ const prepared = prepareAgentLifecycleChange(input, context);
226
+ const change = operation === "pause" ? pauseBuilderBuildRun : resumeBuilderBuildRun;
227
+ const at = new Date().toISOString();
228
+ const run = await change({
229
+ cwd: context.projectRoot,
230
+ runId: context.slug,
231
+ request: { reason: input.reason, authority: { kind: "operator_request", actor: prepared.actorKey, request_ref: `flow-agents://assignment/${context.slug}/${operation}/${at}`, requested_at: at } },
232
+ });
233
+ const projection = projectFlowRun(context, run, prepared.sidecarSnapshot.state);
234
+ writeProjection(context, projection, prepared.sidecarSnapshot.raw, `${operation} projection`);
235
+ return {
236
+ sessionDir: context.sessionDir,
237
+ projectRoot: context.projectRoot,
238
+ run,
239
+ projection,
240
+ attached: false,
241
+ };
242
+ });
243
+ }
244
+
245
+ async function prepareAuthorizedLifecycleChange(input: BuilderFlowAuthorizedLifecycleInput, operation: "cancel" | "archive", context: SessionContext): Promise<{
246
+ context: SessionContext;
247
+ sidecarSnapshot: SidecarSnapshot;
248
+ authorization: ReturnType<typeof loadBuilderLifecycleAuthorization>;
249
+ }> {
250
+ const sidecarSnapshot = readSidecarSnapshot(context);
251
+ const subject = workflowSubject(sidecarSnapshot.state);
252
+ const activeAssignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
253
+ const assignmentFile = assignmentFilePath(context.artifactRoot, context.slug);
254
+ const persistedAssignment = pathExistsNoFollow(assignmentFile)
255
+ ? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")) as AnyRecord)
256
+ : null;
257
+ const canonicalRun = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
258
+ const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
259
+ const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
260
+ if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
261
+ throw new BuilderBuildRunInputError("assignment", "must be actively held by a canonical actor before a lifecycle change");
262
+ }
263
+ if (assignment.work_item_ref && assignment.work_item_ref !== subject) {
264
+ throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
265
+ }
266
+ const authorization = loadBuilderLifecycleAuthorization(input.authorizationFile, {
267
+ projectRoot: context.projectRoot,
268
+ operation,
269
+ runId: context.slug,
270
+ subject,
271
+ actorKey: assignment.actor_key,
272
+ ...(operation === "cancel" && canonicalRun.state.status === "canceled" ? { allowExpired: true } : {}),
273
+ ...(operation === "archive" && sidecarSnapshot.state.status === "archived" ? { allowExpired: true } : {}),
274
+ });
275
+ if (operation === "cancel" && canonicalRun.state.status === "canceled") {
276
+ const terminalEvent = canonicalRun.state.lifecycle?.at(-1);
277
+ if (!terminalEvent || terminalEvent.action !== "cancel" || !lifecycleRequestMatches(terminalEvent, authorization.request)) {
278
+ throw new BuilderBuildRunInputError("authorization.request", "does not match the canonical cancellation being recovered");
279
+ }
280
+ }
281
+ if (!sameActor(authorization.assignment_actor, assignment.actor)) {
282
+ throw new BuilderBuildRunInputError("authorization.assignment_actor", "must match the active assignment holder");
283
+ }
284
+ return { context, sidecarSnapshot, authorization };
285
+ }
286
+
287
+ function prepareAgentLifecycleChange(input: BuilderFlowAgentLifecycleInput, context: SessionContext): { sidecarSnapshot: SidecarSnapshot; actor: ActorStruct; actorKey: string } {
288
+ if (!input.reason.trim()) throw new BuilderBuildRunInputError("reason", "must be non-empty");
289
+ const resolved = resolveCurrentAssignmentActor();
290
+ const sidecarSnapshot = readSidecarSnapshot(context);
291
+ const subject = workflowSubject(sidecarSnapshot.state);
292
+ const assignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
293
+ if (!assignment || assignment.status !== "claimed" || assignment.actor_key !== resolved.actorKey || !sameActor(assignment.actor, resolved.actor)) {
294
+ throw new BuilderBuildRunInputError("assignment", "must be actively held by the current workflow actor");
295
+ }
296
+ if (assignment.work_item_ref && assignment.work_item_ref !== subject) throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
297
+ return { sidecarSnapshot, actor: resolved.actor, actorKey: resolved.actorKey };
298
+ }
299
+
300
+ function sameActor(left: ActorStruct, right: ActorStruct): boolean {
301
+ return isDeepStrictEqual({ ...left, human: left.human ?? null }, { ...right, human: right.human ?? null });
302
+ }
303
+
304
+ function clearCurrentPointers(artifactRoot: string, slug: string): void {
305
+ const candidates = [path.join(artifactRoot, "current.json")];
306
+ const actorRoot = path.join(artifactRoot, "current");
307
+ if (pathExistsNoFollow(actorRoot)) {
308
+ assertSafeDirectory(actorRoot, artifactRoot, "current directory");
309
+ candidates.push(...fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).map((name) => path.join(actorRoot, name)));
310
+ }
311
+ for (const file of candidates) {
312
+ if (!pathExistsNoFollow(file) || !fs.lstatSync(file).isFile()) continue;
313
+ const root = file === candidates[0] ? artifactRoot : actorRoot;
314
+ if (root === actorRoot) assertSafeDirectory(actorRoot, artifactRoot, "current directory");
315
+ assertSafeFile(file, root, "current pointer");
316
+ let pointer: AnyRecord;
317
+ try {
318
+ pointer = JSON.parse(fs.readFileSync(file, "utf8")) as AnyRecord;
319
+ } catch (error) {
320
+ if (!(error instanceof SyntaxError)) throw error;
321
+ // Archival retains malformed unrelated pointers for explicit repair.
322
+ continue;
323
+ }
324
+ if (pointer.active_slug === slug) fs.unlinkSync(file);
325
+ }
326
+ }
327
+
122
328
  export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
123
329
  let context: SessionContext;
124
330
  try {
@@ -291,7 +497,9 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
291
497
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
292
498
  const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
293
499
  const complete = run.state.status === "completed";
294
- const action = complete ? { skills: [], operations: [] } : stepAction(run.state.current_step);
500
+ const paused = run.state.status === "paused";
501
+ const canceled = run.state.status === "canceled";
502
+ const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
295
503
  if (!action) {
296
504
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
297
505
  }
@@ -300,7 +508,7 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
300
508
  .map((expectation: FlowExpectation) => `${expectation.id} (${expectation.bundle_claim.claimType}/${expectation.bundle_claim.subjectType ?? "any"})`));
301
509
  const skills = action?.skills ?? [];
302
510
  const operations = action?.operations ?? [];
303
- const syncCommand = `flow-agents builder-run sync --session-dir .kontourai/flow-agents/${context.slug}`;
511
+ const syncCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), ["workflow", "status", "--session-dir", `.kontourai/flow-agents/${context.slug}`, "--json"]);
304
512
  const routeBack = latestRouteBack(run.state);
305
513
  const skillText = skills.length ? `Activate ${skills.map((skill) => `\`${skill}\``).join(" then ")}.` : "No Builder skill is required.";
306
514
  const operationText = operations.length ? ` Perform ${operations.map((operation) => `\`${operation}\``).join(" then ")}.` : "";
@@ -312,6 +520,10 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
312
520
  : "";
313
521
  const nextAction = complete
314
522
  ? { status: "done", summary: "Canonical Flow run is complete." }
523
+ : canceled
524
+ ? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
525
+ : paused
526
+ ? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
315
527
  : {
316
528
  status: "continue",
317
529
  summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
@@ -322,8 +534,8 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
322
534
  const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
323
535
  return {
324
536
  ...sidecar,
325
- status: complete ? "delivered" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
326
- phase: complete ? "done" : phase,
537
+ status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
538
+ phase: complete || canceled ? "done" : phase,
327
539
  updated_at: run.state.updated_at,
328
540
  flow_run: {
329
541
  run_id: run.runId,