@kontourai/flow-agents 3.6.0 → 3.8.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 (96) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +407 -51
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +80 -14
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +825 -103
  11. package/build/src/cli/workflow.js +301 -43
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/build-universal-bundles.js +53 -3
  18. package/build/src/tools/generate-context-map.js +5 -3
  19. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  20. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  21. package/docs/context-map.md +22 -20
  22. package/docs/developer-architecture.md +1 -1
  23. package/docs/getting-started.md +9 -1
  24. package/docs/public-workflow-cli.md +76 -7
  25. package/docs/skills-map.md +51 -27
  26. package/docs/spec/builder-flow-runtime.md +75 -40
  27. package/docs/workflow-usage-guide.md +109 -42
  28. package/evals/fixtures/hook-influence/cases.json +2 -2
  29. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  30. package/evals/integration/test_builder_step_producers.sh +330 -6
  31. package/evals/integration/test_bundle_install.sh +318 -65
  32. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  33. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  34. package/evals/integration/test_current_json_per_actor.sh +12 -0
  35. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  36. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  37. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  38. package/evals/integration/test_gate_lockdown.sh +3 -5
  39. package/evals/integration/test_goal_fit_hook.sh +60 -2
  40. package/evals/integration/test_install_merge.sh +18 -8
  41. package/evals/integration/test_liveness_verdict.sh +14 -17
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  43. package/evals/integration/test_public_workflow_cli.sh +334 -14
  44. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  45. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  46. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  47. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  48. package/evals/run.sh +2 -0
  49. package/evals/static/test_builder_skill_coherence.sh +247 -0
  50. package/evals/static/test_library_exports.sh +5 -2
  51. package/evals/static/test_universal_bundles.sh +44 -1
  52. package/evals/static/test_workflow_skills.sh +13 -325
  53. package/kits/builder/flows/build.flow.json +22 -0
  54. package/kits/builder/flows/shape.flow.json +9 -9
  55. package/kits/builder/kit.json +70 -16
  56. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  57. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  58. package/kits/builder/skills/deliver/SKILL.md +96 -442
  59. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  60. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  61. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  62. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  63. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  64. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  65. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  66. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  67. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  68. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  69. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  70. package/kits/builder/skills/review-work/SKILL.md +89 -176
  71. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  72. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  73. package/package.json +2 -2
  74. package/packaging/README.md +1 -1
  75. package/scripts/README.md +1 -1
  76. package/scripts/hooks/lib/kit-catalog.js +1 -1
  77. package/scripts/hooks/stop-goal-fit.js +78 -9
  78. package/scripts/install-codex-home.sh +63 -23
  79. package/scripts/install-owned-files.js +18 -2
  80. package/src/builder-flow-run-adapter.ts +118 -32
  81. package/src/builder-flow-runtime.ts +426 -64
  82. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  83. package/src/cli/assignment-provider.ts +75 -14
  84. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  85. package/src/cli/builder-flow-runtime.test.mjs +436 -18
  86. package/src/cli/builder-run.ts +3 -9
  87. package/src/cli/kit-metadata-security.test.mjs +232 -6
  88. package/src/cli/public-api.test.mjs +15 -0
  89. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  90. package/src/cli/workflow-sidecar.ts +746 -103
  91. package/src/cli/workflow.ts +288 -41
  92. package/src/flow-kit/validate.ts +320 -2
  93. package/src/index.ts +6 -5
  94. package/src/lib/observed-command.ts +96 -0
  95. package/src/tools/build-universal-bundles.ts +51 -3
  96. package/src/tools/generate-context-map.ts +5 -3
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { createRequire } from "node:module";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { parseArgs, flagString } from "../lib/args.js";
@@ -186,18 +187,27 @@ function subjectLockDir(artifactRoot, subjectId) {
186
187
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
187
188
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
188
189
  }
190
+ // Lock age is adjudicated by the current contender, never by metadata written
191
+ // by the lock owner. The environment is only an operator tuning input; clamp it
192
+ // so a caller cannot turn a transient owner-file write into immediate takeover.
193
+ const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
194
+ const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
195
+ const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
196
+ function trustedSubjectLockStaleMs() {
197
+ const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
198
+ if (!Number.isFinite(configured))
199
+ return SUBJECT_LOCK_STALE_DEFAULT_MS;
200
+ return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
201
+ }
189
202
  /**
190
203
  * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
191
204
  * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
192
205
  * could both read "no conflicting claim" before either wrote, and the second write would silently
193
206
  * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
194
- * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
195
- * workflow-sidecar.ts:908 for the same class of shared-state mutation atomic `fs.mkdirSync`
196
- * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
197
- * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
198
- * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
199
- * as a small LOCAL helper (not a cross-import of that private function, which would pull the
200
- * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
207
+ * races against the built CLI). Atomic directory creation establishes ownership before metadata
208
+ * is written; contenders treat even an ownerless directory as held. Live contention waits with a
209
+ * bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
210
+ * filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
201
211
  * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
202
212
  * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
203
213
  * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
@@ -208,23 +218,33 @@ function subjectLockDir(artifactRoot, subjectId) {
208
218
  */
209
219
  export function withSubjectLock(artifactRoot, subjectId, body) {
210
220
  const lockDir = subjectLockDir(artifactRoot, subjectId);
211
- const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
221
+ const staleMs = trustedSubjectLockStaleMs();
222
+ const token = randomBytes(16).toString("hex");
223
+ const ownerFile = path.join(lockDir, "owner.json");
212
224
  const deadline = Date.now() + 30000;
213
225
  while (true) {
226
+ let createdLockDir = false;
214
227
  try {
215
228
  fs.mkdirSync(lockDir);
229
+ createdLockDir = true;
230
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
216
231
  break;
217
232
  }
218
233
  catch (error) {
219
234
  const lockError = error;
235
+ if (createdLockDir)
236
+ fs.rmSync(lockDir, { recursive: true, force: true });
220
237
  if (lockError.code !== "EEXIST") {
221
238
  throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
222
239
  }
223
240
  try {
224
- const stat = fs.statSync(lockDir);
225
- if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
226
- fs.rmSync(lockDir, { recursive: true, force: true });
227
- continue;
241
+ const owner = readSubjectLockOwner(ownerFile);
242
+ const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
243
+ if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
244
+ throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
245
+ }
246
+ if (Date.now() - stat.mtimeMs > staleMs) {
247
+ throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
228
248
  }
229
249
  }
230
250
  catch (statError) {
@@ -238,7 +258,14 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
238
258
  sleepSync(20);
239
259
  }
240
260
  }
241
- const release = () => fs.rmSync(lockDir, { recursive: true, force: true });
261
+ let heartbeat;
262
+ const ownsLock = () => readSubjectLockOwner(ownerFile)?.token === token;
263
+ const release = () => {
264
+ if (heartbeat)
265
+ clearInterval(heartbeat);
266
+ if (ownsLock())
267
+ fs.rmSync(lockDir, { recursive: true, force: true });
268
+ };
242
269
  let result;
243
270
  try {
244
271
  result = body();
@@ -248,11 +275,38 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
248
275
  throw error;
249
276
  }
250
277
  if (result && typeof result.then === "function") {
278
+ // An async owner can legitimately hold the lock longer than the stale-lock
279
+ // threshold while an authority-bound command is running. Keep its mtime fresh
280
+ // so lifecycle operations and takeovers continue to observe the live lock.
281
+ const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
282
+ heartbeat = setInterval(() => {
283
+ try {
284
+ if (!ownsLock())
285
+ return;
286
+ const timestamp = new Date();
287
+ fs.utimesSync(ownerFile, timestamp, timestamp);
288
+ fs.utimesSync(lockDir, timestamp, timestamp);
289
+ }
290
+ catch { /* release, reclamation, or process teardown owns cleanup */ }
291
+ }, heartbeatMs);
251
292
  return Promise.resolve(result).finally(release);
252
293
  }
253
294
  release();
254
295
  return result;
255
296
  }
297
+ function readSubjectLockOwner(file) {
298
+ try {
299
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
300
+ return value && typeof value === "object" && !Array.isArray(value)
301
+ ? value
302
+ : null;
303
+ }
304
+ catch (error) {
305
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
306
+ return null;
307
+ throw error;
308
+ }
309
+ }
256
310
  /**
257
311
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
258
312
  * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
@@ -572,7 +626,19 @@ export function performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy
572
626
  // seam, relocated to this write path).
573
627
  const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
574
628
  const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
575
- if (holderActorKey !== releasedByActorKey) {
629
+ // Pre-3.7 lifecycle events could persist the derived ancestry actor before
630
+ // sanitizeSegment removed ':' separators. Modern explicit/env release paths
631
+ // always use the sanitized form. Accept only that one-way legacy migration;
632
+ // never normalize two modern keys or relax ownership to a prefix match.
633
+ const sameActorStruct = existing.actor.runtime === releasedBy.runtime
634
+ && existing.actor.session_id === releasedBy.session_id
635
+ && existing.actor.host === releasedBy.host
636
+ && (existing.actor.human ?? null) === (releasedBy.human ?? null);
637
+ const legacyActorKeyMatches = holderActorKey.includes(":")
638
+ && holderActorKey === helper.serializeActor(existing.actor)
639
+ && helper.sanitizeSegment(holderActorKey) === releasedByActorKey
640
+ && sameActorStruct;
641
+ if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
576
642
  if (tolerateNoActiveClaim)
577
643
  return null;
578
644
  throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
@@ -1,6 +1,6 @@
1
1
  import { flagString, parseArgs } from "../lib/args.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>]";
2
+ import { cancelBuilderFlowSession, archiveBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, } from "../builder-flow-runtime.js";
3
+ const USAGE = "Usage: flow-agents builder-run <recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
4
4
  export async function main(argv) {
5
5
  const parsed = parseArgs(argv);
6
6
  const action = parsed.positionals[0];
@@ -18,7 +18,7 @@ export async function main(argv) {
18
18
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
19
19
  return 64;
20
20
  }
21
- if (!action || !["start", "sync", "recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
21
+ if (!action || !["recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
22
22
  console.error(USAGE);
23
23
  return 64;
24
24
  }
@@ -42,21 +42,17 @@ export async function main(argv) {
42
42
  console.error(USAGE);
43
43
  return 64;
44
44
  }
45
- const result = action === "start"
46
- ? await startBuilderFlowSession({ sessionDir })
47
- : action === "sync"
48
- ? await syncBuilderFlowSession({ 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 });
45
+ const result = action === "recover"
46
+ ? await recoverBuilderFlowSession({ sessionDir })
47
+ : action === "pause"
48
+ ? await pauseBuilderFlowSession({ sessionDir, reason: reason })
49
+ : action === "resume"
50
+ ? await resumeBuilderFlowSession({ sessionDir, reason: reason })
51
+ : action === "cancel"
52
+ ? await cancelBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile })
53
+ : action === "release-assignment"
54
+ ? await releaseBuilderFlowAssignment({ sessionDir, reason: reason })
55
+ : await archiveBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile });
60
56
  console.log(JSON.stringify({
61
57
  run_id: result.run.runId,
62
58
  definition_id: result.run.definitionId,
@@ -174,10 +174,13 @@ export declare function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefin
174
174
  * @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
175
175
  * @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
176
176
  * @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
177
- * @param critiques Critique objects (from critique.json .critiques array)
177
+ * @param critiques Critique objects reconstructed from trust.bundle claims
178
178
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
179
179
  */
180
- export declare function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null>;
180
+ export declare function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string, exactFlowContext?: {
181
+ flowId: string;
182
+ stepId: string;
183
+ }): Promise<AnyObj | null>;
181
184
  /**
182
185
  * Fail-open wrapper: builds (via Surface), validates, and writes a trust.bundle.
183
186
  * Accepts raw check/criterion/critique inputs directly (ADR 0010 Phase 4a).
@@ -191,7 +194,10 @@ export declare function buildTrustBundle(slug: string, timestamp: string, checks
191
194
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
192
195
  * @param critiques Critique objects (same as buildTrustBundle)
193
196
  */
194
- export declare function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{
197
+ export declare function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string, exactFlowContext?: {
198
+ flowId: string;
199
+ stepId: string;
200
+ }): Promise<{
195
201
  written: boolean;
196
202
  errors: string[];
197
203
  }>;
@@ -199,6 +205,23 @@ export declare function sidecarBase(slug: string): AnyObj;
199
205
  export declare function currentWorkflowSessionDir(root: string): string | null;
200
206
  export declare function validateEvidenceRef(ref: AnyObj, label: string): AnyObj;
201
207
  export declare function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[];
208
+ /** Validate test-evidence command shape without executing it. */
209
+ type TestExecutionProof = {
210
+ kind: "local-process-exit";
211
+ runner: string;
212
+ static_test_units: number;
213
+ };
214
+ /**
215
+ * Produce evidence from the locally executed command and statically reviewable
216
+ * test units. Runner stdout is deliberately excluded: any executable can print
217
+ * a Vitest/Jest-looking success summary, but it cannot turn a non-test script
218
+ * into a supported test workflow or supply this locally-created proof.
219
+ */
220
+ export declare function testExecutionProof(command: string, projectRoot: string, seenScripts?: Set<string>, packageScriptBody?: boolean): TestExecutionProof | null;
221
+ /** Validate test-evidence command shape without executing it. */
222
+ export declare function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts?: Set<string>, packageScriptBody?: boolean): boolean;
223
+ export declare function observedExecutedTestCount(output: string): number;
224
+ export declare function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts?: Set<string>): number;
202
225
  export declare function normalizeCheck(raw: AnyObj, allowGateClaimPrefix?: boolean, existingCheckStampById?: ReadonlyMap<string, boolean>): AnyObj;
203
226
  /**
204
227
  * Derive kit identity from a parsed trust.bundle by structurally reading the
@@ -624,4 +647,5 @@ export declare function buildGateInquiryRecords(bundle: BundleFile, blockSignal:
624
647
  export declare const LIVENESS_TERMINAL: Set<string>;
625
648
  export { buildClaimExplanation } from "./sidecar-claim-explain.js";
626
649
  export type { ClaimEvidenceItem, ClaimExplanation } from "./sidecar-claim-explain.js";
627
- export declare function main(argv?: string[]): Promise<number>;
650
+ export declare function mainFromPublicWorkflow(argv: string[]): Promise<number>;
651
+ export declare function main(argv?: string[], authority?: symbol): Promise<number>;