@kontourai/flow-agents 3.5.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.
Files changed (111) 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 +32 -3
  4. package/build/src/builder-flow-run-adapter.js +113 -20
  5. package/build/src/builder-flow-runtime.d.ts +26 -3
  6. package/build/src/builder-flow-runtime.js +502 -49
  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 +14 -7
  10. package/build/src/cli/assignment-provider.js +128 -65
  11. package/build/src/cli/builder-run.js +46 -9
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +30 -3
  14. package/build/src/cli/workflow-sidecar.js +825 -99
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +769 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/flow-kit/validate.d.ts +17 -0
  19. package/build/src/flow-kit/validate.js +340 -2
  20. package/build/src/index.d.ts +4 -0
  21. package/build/src/index.js +7 -5
  22. package/build/src/lib/observed-command.d.ts +7 -0
  23. package/build/src/lib/observed-command.js +100 -0
  24. package/build/src/lib/package-version.d.ts +2 -0
  25. package/build/src/lib/package-version.js +13 -0
  26. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  27. package/build/src/lib/pinned-cli-command.js +21 -0
  28. package/build/src/tools/generate-context-map.js +5 -3
  29. package/context/contracts/artifact-contract.md +1 -1
  30. package/context/scripts/hooks/config-protection.js +8 -1
  31. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  32. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  33. package/context/scripts/hooks/stop-goal-fit.js +79 -10
  34. package/docs/context-map.md +24 -20
  35. package/docs/developer-architecture.md +1 -1
  36. package/docs/public-workflow-cli.md +132 -0
  37. package/docs/skills-map.md +51 -27
  38. package/docs/spec/builder-flow-runtime.md +78 -40
  39. package/docs/workflow-usage-guide.md +110 -38
  40. package/evals/ci/run-baseline.sh +2 -0
  41. package/evals/fixtures/hook-influence/cases.json +2 -2
  42. package/evals/integration/test_builder_entry_enforcement.sh +57 -45
  43. package/evals/integration/test_builder_step_producers.sh +297 -6
  44. package/evals/integration/test_bundle_install.sh +258 -55
  45. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  46. package/evals/integration/test_current_json_per_actor.sh +12 -0
  47. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  48. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  49. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  50. package/evals/integration/test_gate_lockdown.sh +3 -5
  51. package/evals/integration/test_goal_fit_hook.sh +60 -2
  52. package/evals/integration/test_liveness_verdict.sh +14 -17
  53. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  54. package/evals/integration/test_public_workflow_cli.sh +573 -0
  55. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  56. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  57. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  58. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  59. package/evals/run.sh +2 -0
  60. package/evals/static/test_builder_skill_coherence.sh +247 -0
  61. package/evals/static/test_library_exports.sh +5 -2
  62. package/evals/static/test_workflow_skills.sh +13 -325
  63. package/kits/builder/flows/build.flow.json +22 -0
  64. package/kits/builder/flows/shape.flow.json +9 -9
  65. package/kits/builder/kit.json +70 -16
  66. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  67. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  68. package/kits/builder/skills/deliver/SKILL.md +96 -442
  69. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  70. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  71. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  72. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  73. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  74. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  75. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  76. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  77. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  78. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  79. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  80. package/kits/builder/skills/review-work/SKILL.md +89 -176
  81. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  82. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  83. package/package.json +2 -2
  84. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  85. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  86. package/schemas/workflow-state.schema.json +1 -1
  87. package/scripts/hooks/config-protection.js +8 -1
  88. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  89. package/scripts/hooks/lib/kit-catalog.js +1 -1
  90. package/scripts/hooks/stop-goal-fit.js +79 -10
  91. package/src/builder-flow-run-adapter.ts +156 -23
  92. package/src/builder-flow-runtime.ts +535 -53
  93. package/src/builder-lifecycle-authority.ts +218 -0
  94. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  95. package/src/cli/assignment-provider.ts +91 -22
  96. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  97. package/src/cli/builder-flow-runtime.test.mjs +597 -8
  98. package/src/cli/builder-run.ts +54 -9
  99. package/src/cli/kit-metadata-security.test.mjs +232 -6
  100. package/src/cli/public-api.test.mjs +15 -0
  101. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  102. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  103. package/src/cli/workflow-sidecar.ts +748 -99
  104. package/src/cli/workflow.ts +708 -0
  105. package/src/cli.ts +2 -0
  106. package/src/flow-kit/validate.ts +320 -2
  107. package/src/index.ts +20 -5
  108. package/src/lib/observed-command.ts +96 -0
  109. package/src/lib/package-version.ts +15 -0
  110. package/src/lib/pinned-cli-command.ts +23 -0
  111. package/src/tools/generate-context-map.ts +5 -3
@@ -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;
@@ -1759,6 +1759,37 @@ function hasSidecarPresence(artifactDir) {
1759
1759
  return fs.existsSync(path.join(artifactDir, 'state.json')) || fs.existsSync(path.join(artifactDir, 'trust.bundle'));
1760
1760
  }
1761
1761
 
1762
+ function canonicalFlowState(root, artifactDir) {
1763
+ if (!artifactDir) return { state: null, error: null };
1764
+ const slug = path.basename(artifactDir);
1765
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return { state: null, error: 'canonical Flow run slug is malformed' };
1766
+ const runDir = path.join(root, '.kontourai', 'flow', 'runs', slug);
1767
+ const components = [
1768
+ path.join(root, '.kontourai'),
1769
+ path.join(root, '.kontourai', 'flow'),
1770
+ path.join(root, '.kontourai', 'flow', 'runs'),
1771
+ runDir,
1772
+ ];
1773
+ try {
1774
+ for (const component of components) {
1775
+ const stat = fs.lstatSync(component);
1776
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return { state: null, error: `canonical Flow run has an unsafe parent component: ${component}` };
1777
+ }
1778
+ const file = path.join(runDir, 'state.json');
1779
+ const stat = fs.lstatSync(file);
1780
+ if (stat.isSymbolicLink() || !stat.isFile()) return { state: null, error: 'canonical Flow state must be a non-symlink regular file' };
1781
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
1782
+ if (!state || typeof state !== 'object' || Array.isArray(state)
1783
+ || typeof state.status !== 'string' || !state.status.trim()
1784
+ || typeof state.current_step !== 'string' || !state.current_step.trim()) {
1785
+ return { state: null, error: 'canonical Flow state is malformed' };
1786
+ }
1787
+ return { state, error: null };
1788
+ } catch (error) {
1789
+ return { state: null, error: `canonical Flow state is unavailable or malformed: ${safeOneLine(error && error.message || error, 120)}` };
1790
+ }
1791
+ }
1792
+
1762
1793
  // WS8 (AC10a): when current.json names a slug whose session directory does NOT exist,
1763
1794
  // return that slug so analyze() can log the staleness rather than silently falling back to
1764
1795
  // a global mtime scan that could resurface an abandoned/never-real session as active.
@@ -1871,7 +1902,7 @@ function missingBundleOrStateSignal(artifactDir, activeFlowStep) {
1871
1902
  //
1872
1903
  // Both are used in analyze() for blocking decisions AND in run() for the AC2
1873
1904
  // MAX_BLOCKS hard-block guard (preventing auto-release of hard blocks).
1874
- const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered|NOT_VERIFIED \(ambiguous\)/;
1905
+ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered|NOT_VERIFIED \(ambiguous\)|canonical Flow (?:run remains active|state is unsafe or malformed)/;
1875
1906
  // FULL_BLOCK adds: workflow-state hygiene, surface-unavailable fail-closed, missing log.
1876
1907
  const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence|NOT_VERIFIED \(ambiguous\)/;
1877
1908
 
@@ -1897,7 +1928,31 @@ async function analyze(root, now = Date.now()) {
1897
1928
  artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
1898
1929
  }
1899
1930
 
1900
- if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
1931
+ const scopedCanonicalFlow = canonicalFlowState(root, scoped);
1932
+ const scopedState = scoped ? readJsonFile(path.join(scoped, 'state.json')) : null;
1933
+ const scopedProjectedActive = Boolean(scopedState && scopedState.flow_run && normalizedStatus(scopedState.flow_run.status) === 'active');
1934
+ if (scopedProjectedActive && scopedCanonicalFlow.error) {
1935
+ return {
1936
+ warnings: [`workflow state: canonical Flow state is unsafe or malformed for the active scoped session: ${scopedCanonicalFlow.error}. Resolve the canonical run before stopping.`],
1937
+ blocking: true,
1938
+ activeFlowRun: true,
1939
+ latestArtifactDir: scoped,
1940
+ gatePrefix: '[stop-gate]',
1941
+ };
1942
+ }
1943
+ if (artifacts.length === 0) {
1944
+ if (normalizedStatus(scopedCanonicalFlow.state?.status) === 'active') {
1945
+ const activeStep = safeOneLine(scopedCanonicalFlow.state.current_step || 'unknown', 80);
1946
+ return {
1947
+ warnings: [`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`],
1948
+ blocking: true,
1949
+ activeFlowRun: true,
1950
+ latestArtifactDir: scoped,
1951
+ gatePrefix: '[stop-gate]',
1952
+ };
1953
+ }
1954
+ return { warnings: [], blocking: false, activeFlowRun: false, latestArtifactDir: null };
1955
+ }
1901
1956
 
1902
1957
  const latest = artifacts[0];
1903
1958
  const latestArtifactDir = path.dirname(latest.file);
@@ -1965,14 +2020,25 @@ async function analyze(root, now = Date.now()) {
1965
2020
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1966
2021
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
1967
2022
  // execution-onward tasks: FULL_BLOCK signals cause a block.
1968
- const activeFlowRun = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
2023
+ const canonicalFlow = canonicalFlowState(root, latestArtifactDir);
2024
+ const activeProjectedFlow = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
2025
+ const unsafeActiveCanonical = Boolean(activeProjectedFlow && canonicalFlow.error);
2026
+ if (unsafeActiveCanonical) {
2027
+ warnings.push(`workflow state: canonical Flow state is unsafe or malformed for the active scoped session: ${canonicalFlow.error}. Resolve the canonical run before stopping.`);
2028
+ }
2029
+ const activeFlowRun = normalizedStatus(canonicalFlow.state?.status) === 'active'
2030
+ || (gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active');
2031
+ if (activeFlowRun && !warnings.some(w => /canonical Flow run remains active/.test(w))) {
2032
+ const activeStep = safeOneLine(canonicalFlow.state?.current_step || gateState?.flow_run?.current_step || 'unknown', 80);
2033
+ warnings.push(`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`);
2034
+ }
1969
2035
  const blockRe = ((preExecution && !activeFlowRun) || terminal) ? HARD_BLOCK : FULL_BLOCK;
1970
- const blocking = warnings.some(w => {
2036
+ const blocking = activeFlowRun || warnings.some(w => {
1971
2037
  // Capture cross-reference warn-mode notes never block (operator opted out).
1972
2038
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
1973
2039
  return blockRe.test(w);
1974
2040
  });
1975
- return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
2041
+ return { warnings, blocking, activeFlowRun, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
1976
2042
  }
1977
2043
 
1978
2044
  /**
@@ -2220,8 +2286,9 @@ function releaseOnNonTerminalStop(root, artifactDir) {
2220
2286
 
2221
2287
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
2222
2288
  if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
2223
- if (state.flow_run && normalizedStatus(state.flow_run.status) === 'active') {
2224
- process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run.run_id || state.task_slug || 'unknown', 80)}"; continuation remains governed by Flow state.\n`);
2289
+ const canonicalFlow = canonicalFlowState(root, artifactDir);
2290
+ if (normalizedStatus(canonicalFlow.state?.status) === 'active' || (state.flow_run && normalizedStatus(state.flow_run.status) === 'active')) {
2291
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run?.run_id || state.task_slug || path.basename(artifactDir), 80)}"; continuation remains governed by Flow state.\n`);
2225
2292
  return;
2226
2293
  }
2227
2294
 
@@ -2367,7 +2434,7 @@ async function run(rawInput) {
2367
2434
  // with runtime-constructed paths or by modifying the warning
2368
2435
  // text so the hash changes. The real anchor is external (signed checkpoints + human
2369
2436
  // review). This raises the cost of the burn-through-the-counter escape vector.
2370
- const isHardBlock = result.warnings.some(w => {
2437
+ const isHardBlock = result.activeFlowRun || result.warnings.some(w => {
2371
2438
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
2372
2439
  return HARD_BLOCK.test(w);
2373
2440
  });
@@ -2375,7 +2442,9 @@ async function run(rawInput) {
2375
2442
  // Do NOT clear the streak — keep accumulating so the same hard block stays visible.
2376
2443
  return {
2377
2444
  stdout: rawInput,
2378
- stderr: `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
2445
+ stderr: result.activeFlowRun
2446
+ ? `${message}\n${gatePrefix} max-blocks reached but canonical Flow remains active — not auto-releasing; complete or explicitly cancel the run.`
2447
+ : `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
2379
2448
  exitCode: 2,
2380
2449
  };
2381
2450
  }
@@ -1,19 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  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,
13
- readJson,
14
+ pauseRun,
15
+ resumeRun,
14
16
  startRun,
15
17
  validateDefinition,
16
18
  type FlowEvidenceEntry,
19
+ type FlowLifecycleRequest,
17
20
  type FlowRunState,
18
21
  type GateOutcome,
19
22
  type JsonObject,
@@ -22,6 +25,9 @@ import { resolveEffectiveFlowDefinition } from "./lib/flow-resolver.js";
22
25
 
23
26
  export const BUILDER_BUILD_FLOW_ID = "builder.build";
24
27
  export const BUILDER_BUILD_FLOW_RELATIVE_PATH = "kits/builder/flows/build.flow.json";
28
+ export const BUILDER_SHAPE_FLOW_ID = "builder.shape";
29
+ export const BUILDER_SHAPE_FLOW_RELATIVE_PATH = "kits/builder/flows/shape.flow.json";
30
+ export type BuilderFlowId = typeof BUILDER_BUILD_FLOW_ID | typeof BUILDER_SHAPE_FLOW_ID;
25
31
 
26
32
  export interface BuilderBuildTrustBundleEvidenceInput {
27
33
  gate: string;
@@ -30,6 +36,8 @@ export interface BuilderBuildTrustBundleEvidenceInput {
30
36
  * Callers must not pass raw user-controlled paths to this local runtime API.
31
37
  */
32
38
  file: string;
39
+ /** SHA-256 of the immutable snapshot validated by Flow Agents before Flow attaches it. */
40
+ expectedSha256?: string;
33
41
  status?: "passed" | "failed";
34
42
  producer?: string;
35
43
  authorityTrace?: string;
@@ -52,6 +60,10 @@ export interface StartBuilderBuildRunInput {
52
60
  runId?: string;
53
61
  }
54
62
 
63
+ export interface StartBuilderFlowRunInput extends StartBuilderBuildRunInput {
64
+ flowId: BuilderFlowId;
65
+ }
66
+
55
67
  export interface EvaluateBuilderBuildRunInput {
56
68
  runId: string;
57
69
  /**
@@ -67,8 +79,13 @@ export interface LoadBuilderBuildRunInput {
67
79
  cwd?: string;
68
80
  }
69
81
 
70
- export interface BuilderBuildRunResult {
71
- definitionId: typeof BUILDER_BUILD_FLOW_ID;
82
+ export interface ChangeBuilderBuildRunLifecycleInput extends LoadBuilderBuildRunInput {
83
+ request: FlowLifecycleRequest;
84
+ at?: string;
85
+ }
86
+
87
+ export interface BuilderFlowRunResult {
88
+ definitionId: BuilderFlowId;
72
89
  definitionVersion: string;
73
90
  runId: string;
74
91
  dir: string;
@@ -79,6 +96,10 @@ export interface BuilderBuildRunResult {
79
96
  freshnessTransitions: JsonObject[];
80
97
  }
81
98
 
99
+ export interface BuilderBuildRunResult extends Omit<BuilderFlowRunResult, "definitionId"> {
100
+ definitionId: typeof BUILDER_BUILD_FLOW_ID;
101
+ }
102
+
82
103
  export type BuilderBuildRunIdentityMismatch = "definition-id" | "definition-version" | "definition-content";
83
104
 
84
105
  export class BuilderBuildRunInputError extends Error {
@@ -119,20 +140,29 @@ export class BuilderBuildRunIdentityError extends Error {
119
140
  }
120
141
 
121
142
  export function resolveBuilderBuildFlowDefinitionPath(startDir = moduleDirectory()): string {
122
- const root = findPackageRoot(startDir);
123
- return path.join(root, BUILDER_BUILD_FLOW_RELATIVE_PATH);
143
+ return resolveBuilderFlowDefinitionPath(BUILDER_BUILD_FLOW_ID, startDir);
124
144
  }
125
145
 
126
146
  export async function startBuilderBuildRun(input: StartBuilderBuildRunInput): Promise<BuilderBuildRunResult> {
147
+ const result = await startBuilderFlowRun({ ...input, flowId: BUILDER_BUILD_FLOW_ID });
148
+ return asBuilderBuildResult(result, input.runId ?? result.runId);
149
+ }
150
+
151
+ export function resolveBuilderFlowDefinitionPath(flowId: BuilderFlowId, startDir = moduleDirectory()): string {
152
+ const root = findPackageRoot(startDir);
153
+ return path.join(root, flowRelativePath(flowId));
154
+ }
155
+
156
+ export async function startBuilderFlowRun(input: StartBuilderFlowRunInput): Promise<BuilderFlowRunResult> {
127
157
  assertRuntimeInput(input, ["evidence", "now", "gate"]);
128
158
  if (!isNonEmptyString(input.subject)) {
129
159
  throw new BuilderBuildRunInputError("subject", "must be a non-empty string");
130
160
  }
131
161
 
132
162
  const cwd = input.cwd ?? process.cwd();
133
- const definitionPath = resolveBuilderBuildFlowDefinitionPath();
134
- const definition = await loadShippedBuilderBuildDefinition(definitionPath);
135
- const runtimeDefinitionPath = materializeRuntimeDefinition(cwd, definition);
163
+ const definitionPath = resolveBuilderFlowDefinitionPath(input.flowId);
164
+ const definition = await loadShippedBuilderFlowDefinition(input.flowId, definitionPath);
165
+ const runtimeDefinitionPath = materializeRuntimeDefinition(cwd, input.flowId, definition);
136
166
  const started = await startRun(runtimeDefinitionPath, {
137
167
  cwd,
138
168
  runId: input.runId,
@@ -141,12 +171,17 @@ export async function startBuilderBuildRun(input: StartBuilderBuildRunInput): Pr
141
171
  subject: input.subject,
142
172
  },
143
173
  });
144
- const run = await loadCanonicalBuilderBuildRun(started.runId, cwd, definition);
174
+ const run = await loadCanonicalBuilderFlowRun(started.runId, cwd, definition);
145
175
 
146
176
  return resultFromRun(run, started.runId);
147
177
  }
148
178
 
149
179
  export async function evaluateBuilderBuildRun(input: EvaluateBuilderBuildRunInput): Promise<BuilderBuildRunResult> {
180
+ const result = await evaluateBuilderFlowRun(input);
181
+ return asBuilderBuildResult(result, input.runId);
182
+ }
183
+
184
+ export async function evaluateBuilderFlowRun(input: EvaluateBuilderBuildRunInput): Promise<BuilderFlowRunResult> {
150
185
  assertRuntimeInput(input, ["now", "gate"]);
151
186
  if (Array.isArray(input.evidence)) {
152
187
  throw new BuilderBuildRunInputError("evidence", "must be zero or one evidence object, not an array");
@@ -154,16 +189,26 @@ export async function evaluateBuilderBuildRun(input: EvaluateBuilderBuildRunInpu
154
189
 
155
190
  const cwd = input.cwd ?? process.cwd();
156
191
  const run = await loadRun(input.runId, cwd);
157
- const definition = await loadShippedBuilderBuildDefinition(resolveBuilderBuildFlowDefinitionPath());
192
+ const definition = await loadShippedBuilderFlowDefinitionForRun(input.runId, run.definition);
158
193
  assertCanonicalDefinition(input.runId, definition, run.definition);
159
194
 
160
195
  let attachedEvidence: FlowEvidenceEntry[] = [];
161
196
  if (input.evidence !== undefined) {
162
197
  const evidence = validateEvidenceInput(input.evidence);
163
198
  assertCurrentOpenGate(run.definition, run.state, evidence.gate);
164
- const normalized = normalizeTrustBundle(await readJson(path.resolve(cwd, evidence.file)));
199
+ const source = path.resolve(cwd, evidence.file);
200
+ const bytes = readFileSync(source);
201
+ const validatedSha256 = createHash("sha256").update(bytes).digest("hex");
202
+ if (evidence.expectedSha256 && evidence.expectedSha256 !== validatedSha256) {
203
+ throw new BuilderBuildRunInputError("evidence.expectedSha256", "does not match the bytes presented for validation");
204
+ }
205
+ const normalized = normalizeTrustBundle(JSON.parse(bytes.toString("utf8")));
165
206
  assertBundleSubjects(normalized.bundle, run.state.subject, openGates(run.definition, run.state)[0]);
166
- attachedEvidence = [await attachEvidence(input.runId, trustBundleAttachOptions(cwd, evidence))];
207
+ const attached = await attachEvidence(input.runId, trustBundleAttachOptions(cwd, evidence, validatedSha256));
208
+ if (attached.sha256 !== validatedSha256) {
209
+ throw new BuilderBuildRunInputError("evidence.file", "changed after validation before Flow attachment");
210
+ }
211
+ attachedEvidence = [attached];
167
212
  }
168
213
 
169
214
  const evaluated = await evaluateRun(input.runId, { cwd });
@@ -181,15 +226,73 @@ export async function evaluateBuilderBuildRun(input: EvaluateBuilderBuildRunInpu
181
226
  }
182
227
 
183
228
  export async function loadBuilderBuildRun(input: LoadBuilderBuildRunInput): Promise<BuilderBuildRunResult> {
229
+ const result = await loadBuilderFlowRun(input);
230
+ return asBuilderBuildResult(result, input.runId);
231
+ }
232
+
233
+ export async function loadBuilderFlowRun(input: LoadBuilderBuildRunInput): Promise<BuilderFlowRunResult> {
184
234
  assertRuntimeInput(input, ["evidence", "now", "gate"]);
185
235
  const cwd = input.cwd ?? process.cwd();
186
236
  const run = await loadRun(input.runId, cwd);
187
- const definition = await loadShippedBuilderBuildDefinition(resolveBuilderBuildFlowDefinitionPath());
237
+ const definition = await loadShippedBuilderFlowDefinitionForRun(input.runId, run.definition);
188
238
  assertCanonicalDefinition(input.runId, definition, run.definition);
189
239
  return resultFromRun(run, input.runId);
190
240
  }
191
241
 
192
- function resultFromRun(run: Awaited<ReturnType<typeof loadRun>>, runId: string): BuilderBuildRunResult {
242
+ export async function pauseBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult> {
243
+ const result = await pauseBuilderFlowRun(input);
244
+ return asBuilderBuildResult(result, input.runId);
245
+ }
246
+
247
+ export async function resumeBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult> {
248
+ const result = await resumeBuilderFlowRun(input);
249
+ return asBuilderBuildResult(result, input.runId);
250
+ }
251
+
252
+ export async function cancelBuilderBuildRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderBuildRunResult & { idempotent: boolean }> {
253
+ const changed = await changeBuilderFlowRunLifecycleResult(input, cancelRun);
254
+ return { ...asBuilderBuildResult(resultFromRun(changed, input.runId), input.runId), idempotent: changed.idempotent };
255
+ }
256
+
257
+ export async function pauseBuilderFlowRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderFlowRunResult> {
258
+ return changeBuilderFlowRunLifecycle(input, pauseRun);
259
+ }
260
+
261
+ export async function resumeBuilderFlowRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderFlowRunResult> {
262
+ return changeBuilderFlowRunLifecycle(input, resumeRun);
263
+ }
264
+
265
+ export async function cancelBuilderFlowRun(input: ChangeBuilderBuildRunLifecycleInput): Promise<BuilderFlowRunResult & { idempotent: boolean }> {
266
+ const changed = await changeBuilderFlowRunLifecycleResult(input, cancelRun);
267
+ return { ...resultFromRun(changed, input.runId), idempotent: changed.idempotent };
268
+ }
269
+
270
+ async function changeBuilderFlowRunLifecycle(
271
+ input: ChangeBuilderBuildRunLifecycleInput,
272
+ operation: typeof pauseRun | typeof resumeRun,
273
+ ): Promise<BuilderFlowRunResult> {
274
+ const changed = await changeBuilderFlowRunLifecycleResult(input, operation);
275
+ return resultFromRun(changed, input.runId);
276
+ }
277
+
278
+ async function changeBuilderFlowRunLifecycleResult(
279
+ input: ChangeBuilderBuildRunLifecycleInput,
280
+ operation: typeof pauseRun | typeof resumeRun | typeof cancelRun,
281
+ ) {
282
+ assertRuntimeInput(input, []);
283
+ if (!isRecord(input.request)) throw new BuilderBuildRunInputError("request", "must be a lifecycle request object");
284
+ const cwd = input.cwd ?? process.cwd();
285
+ const before = await loadBuilderFlowRun({ runId: input.runId, cwd });
286
+ const changed = await operation(input.runId, { cwd, ...input.request, ...(input.at ? { at: input.at } : {}) });
287
+ const definition = await loadShippedBuilderFlowDefinitionForRun(input.runId, changed.definition);
288
+ assertCanonicalDefinition(input.runId, definition, changed.definition);
289
+ if (changed.state.subject !== before.state.subject) {
290
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "changed during lifecycle transition");
291
+ }
292
+ return changed;
293
+ }
294
+
295
+ function resultFromRun(run: Awaited<ReturnType<typeof loadRun>>, runId: string): BuilderFlowRunResult {
193
296
  return {
194
297
  definitionId: run.definition.id,
195
298
  definitionVersion: run.definition.version,
@@ -203,7 +306,7 @@ function resultFromRun(run: Awaited<ReturnType<typeof loadRun>>, runId: string):
203
306
  };
204
307
  }
205
308
 
206
- async function loadCanonicalBuilderBuildRun(
309
+ async function loadCanonicalBuilderFlowRun(
207
310
  runId: string,
208
311
  cwd: string,
209
312
  definition: { id: string; version: string },
@@ -213,29 +316,55 @@ async function loadCanonicalBuilderBuildRun(
213
316
  return run;
214
317
  }
215
318
 
216
- async function loadShippedBuilderBuildDefinition(definitionPath: string): Promise<{ id: string; version: string }> {
319
+ async function loadShippedBuilderFlowDefinition(flowId: BuilderFlowId, definitionPath: string): Promise<{ id: string; version: string }> {
217
320
  const packageRoot = findPackageRoot(path.dirname(definitionPath));
218
- const effective = resolveEffectiveFlowDefinition(BUILDER_BUILD_FLOW_ID, packageRoot, { allowOverride: false });
321
+ const effective = resolveEffectiveFlowDefinition(flowId, packageRoot, { allowOverride: false });
219
322
  if (!effective) {
220
323
  throw new BuilderBuildRunInputError("definition", "could not compile the shipped uses_flow composition");
221
324
  }
222
325
  const definition = validateDefinition(effective);
223
- if (definition.id !== BUILDER_BUILD_FLOW_ID) {
224
- throw new BuilderBuildRunInputError("definition", `expected shipped definition id ${BUILDER_BUILD_FLOW_ID}`);
326
+ if (definition.id !== flowId) {
327
+ throw new BuilderBuildRunInputError("definition", `expected shipped definition id ${flowId}`);
225
328
  }
226
329
  return definition;
227
330
  }
228
331
 
229
- function materializeRuntimeDefinition(cwd: string, definition: unknown): string {
332
+ async function loadShippedBuilderFlowDefinitionForRun(runId: string, actualDefinition: { id: string; version: string }): Promise<{ id: string; version: string }> {
333
+ const flowId = actualDefinition.id;
334
+ if (!isBuilderFlowId(flowId)) {
335
+ throw new BuilderBuildRunIdentityError(runId, { id: BUILDER_BUILD_FLOW_ID, version: "unknown" }, actualDefinition, "definition-id");
336
+ }
337
+ return loadShippedBuilderFlowDefinition(flowId, resolveBuilderFlowDefinitionPath(flowId));
338
+ }
339
+
340
+ function materializeRuntimeDefinition(cwd: string, flowId: BuilderFlowId, definition: unknown): string {
230
341
  const content = `${JSON.stringify(definition, null, 2)}\n`;
231
342
  const digest = createHash("sha256").update(content).digest("hex").slice(0, 16);
232
343
  const directory = path.join(cwd, ".kontourai", "flow-agents", "runtime-definitions");
233
344
  mkdirSync(directory, { recursive: true });
234
- const file = path.join(directory, `builder-build-${digest}.flow.json`);
345
+ const file = path.join(directory, `${flowId.replace(".", "-")}-${digest}.flow.json`);
235
346
  if (!existsSync(file)) writeFileSync(file, content);
236
347
  return file;
237
348
  }
238
349
 
350
+ function flowRelativePath(flowId: BuilderFlowId): string {
351
+ return flowId === BUILDER_BUILD_FLOW_ID ? BUILDER_BUILD_FLOW_RELATIVE_PATH : BUILDER_SHAPE_FLOW_RELATIVE_PATH;
352
+ }
353
+
354
+ function isBuilderFlowId(value: string): value is BuilderFlowId {
355
+ return value === BUILDER_BUILD_FLOW_ID || value === BUILDER_SHAPE_FLOW_ID;
356
+ }
357
+
358
+ function assertExpectedFlow(runId: string, actual: BuilderFlowId, expected: BuilderFlowId): void {
359
+ if (actual === expected) return;
360
+ throw new BuilderBuildRunIdentityError(runId, { id: expected, version: "unknown" }, { id: actual, version: "unknown" }, "definition-id");
361
+ }
362
+
363
+ function asBuilderBuildResult(result: BuilderFlowRunResult, runId: string): BuilderBuildRunResult {
364
+ assertExpectedFlow(runId, result.definitionId, BUILDER_BUILD_FLOW_ID);
365
+ return result as BuilderBuildRunResult;
366
+ }
367
+
239
368
  function assertCanonicalDefinition(
240
369
  runId: string,
241
370
  expectedDefinition: { id: string; version: string },
@@ -272,6 +401,9 @@ function validateEvidenceInput(evidence: unknown): BuilderBuildTrustBundleEviden
272
401
  if (!isNonEmptyString(evidence.file)) {
273
402
  throw new BuilderBuildRunInputError("evidence.file", "must be a non-empty string");
274
403
  }
404
+ if (evidence.expectedSha256 !== undefined && (!isNonEmptyString(evidence.expectedSha256) || !/^[a-f0-9]{64}$/i.test(evidence.expectedSha256))) {
405
+ throw new BuilderBuildRunInputError("evidence.expectedSha256", "must be a SHA-256 hex digest");
406
+ }
275
407
  if (evidence.status !== undefined && evidence.status !== "passed" && evidence.status !== "failed") {
276
408
  throw new BuilderBuildRunInputError("evidence.status", "must be passed or failed");
277
409
  }
@@ -311,11 +443,12 @@ function assertBundleSubjects(bundle: unknown, subject: string, gate: unknown):
311
443
  }
312
444
  }
313
445
 
314
- function trustBundleAttachOptions(cwd: string, evidence: BuilderBuildTrustBundleEvidenceInput): JsonObject {
446
+ function trustBundleAttachOptions(cwd: string, evidence: BuilderBuildTrustBundleEvidenceInput, expectedSha256: string): JsonObject {
315
447
  return {
316
448
  cwd,
317
449
  gate: evidence.gate,
318
450
  file: evidence.file,
451
+ expectedSha256,
319
452
  kind: "trust.bundle",
320
453
  bundle: true,
321
454
  ...(evidence.status ? { status: evidence.status } : {}),