@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
@@ -0,0 +1,83 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { syncBuiltinESMExports } from "node:module";
7
+
8
+ import { withSubjectLock } from "../../build/src/cli/assignment-provider.js";
9
+
10
+ test("a displaced lock owner cannot heartbeat or release a replacement lock", async () => {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-aba-"));
12
+ const subject = "lock-aba";
13
+ const lockDir = path.join(root, "assignment", ".lock-aba.lockdir");
14
+ let finish;
15
+ const held = withSubjectLock(root, subject, () => new Promise((resolve) => { finish = resolve; }));
16
+ const original = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
17
+
18
+ const quarantine = `${lockDir}.stale-test`;
19
+ fs.renameSync(lockDir, quarantine);
20
+ fs.mkdirSync(lockDir);
21
+ const replacement = { token: "replacement-owner", stale_after_ms: 300000 };
22
+ fs.writeFileSync(path.join(lockDir, "owner.json"), `${JSON.stringify(replacement)}\n`);
23
+ fs.rmSync(quarantine, { recursive: true, force: true });
24
+
25
+ finish();
26
+ await held;
27
+ assert.equal(fs.existsSync(lockDir), true);
28
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")), replacement);
29
+ assert.notEqual(original.token, replacement.token);
30
+ });
31
+
32
+ test("stale locks fail closed instead of risking concurrent reclamation", () => {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-stale-"));
34
+ const lockDir = path.join(root, "assignment", ".lock-stale.lockdir");
35
+ fs.mkdirSync(lockDir, { recursive: true });
36
+ const ownerFile = path.join(lockDir, "owner.json");
37
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token: "old-owner", stale_after_ms: 1 })}\n`);
38
+ // The owner-provided one-millisecond threshold is ignored. Reclamation uses
39
+ // the contender's bounded policy, so this must be older than its default.
40
+ const old = new Date(Date.now() - 10 * 60 * 1000);
41
+ fs.utimesSync(ownerFile, old, old);
42
+
43
+ assert.throws(() => withSubjectLock(root, "lock-stale", () => "unreachable"), /requires explicit operator cleanup/);
44
+ assert.equal(fs.existsSync(lockDir), true);
45
+ });
46
+
47
+ test("old ownerless and malformed locks fail closed without a busy-spin", () => {
48
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-malformed-"));
49
+ const assignmentDir = path.join(root, "assignment");
50
+ for (const [subject, owner] of [["ownerless", null], ["malformed", "not-json\n"]]) {
51
+ const lockDir = path.join(assignmentDir, `.${subject}.lockdir`);
52
+ fs.mkdirSync(lockDir, { recursive: true });
53
+ if (owner !== null) fs.writeFileSync(path.join(lockDir, "owner.json"), owner);
54
+ const old = new Date(Date.now() - 10 * 60 * 1000);
55
+ fs.utimesSync(lockDir, old, old);
56
+ if (owner !== null) fs.utimesSync(path.join(lockDir, "owner.json"), old, old);
57
+ const started = Date.now();
58
+ assert.throws(() => withSubjectLock(root, subject, () => "unreachable"), /requires explicit operator cleanup/);
59
+ assert.ok(Date.now() - started < 1_000, `${subject} stale lock should fail without spinning until deadline`);
60
+ assert.equal(fs.existsSync(lockDir), true);
61
+ }
62
+ });
63
+
64
+ test("failed owner metadata creation removes the ownerless lock directory", () => {
65
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-owner-write-"));
66
+ const originalWriteFileSync = fs.writeFileSync;
67
+ fs.writeFileSync = function injectedOwnerWriteFailure(file, ...args) {
68
+ if (path.basename(String(file)) === "owner.json") {
69
+ const error = new Error("injected owner write failure");
70
+ error.code = "EACCES";
71
+ throw error;
72
+ }
73
+ return originalWriteFileSync.call(this, file, ...args);
74
+ };
75
+ syncBuiltinESMExports();
76
+ try {
77
+ assert.throws(() => withSubjectLock(root, "owner-write", () => "unreachable"), /failed to acquire assignment lock/);
78
+ } finally {
79
+ fs.writeFileSync = originalWriteFileSync;
80
+ syncBuiltinESMExports();
81
+ }
82
+ assert.equal(fs.existsSync(path.join(root, "assignment", ".owner-write.lockdir")), false);
83
+ });
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { createRequire } from "node:module";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
@@ -309,18 +310,28 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
309
310
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
310
311
  }
311
312
 
313
+ // Lock age is adjudicated by the current contender, never by metadata written
314
+ // by the lock owner. The environment is only an operator tuning input; clamp it
315
+ // so a caller cannot turn a transient owner-file write into immediate takeover.
316
+ const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
317
+ const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
318
+ const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
319
+
320
+ function trustedSubjectLockStaleMs(): number {
321
+ const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
322
+ if (!Number.isFinite(configured)) return SUBJECT_LOCK_STALE_DEFAULT_MS;
323
+ return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
324
+ }
325
+
312
326
  /**
313
327
  * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
314
328
  * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
315
329
  * could both read "no conflicting claim" before either wrote, and the second write would silently
316
330
  * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
317
- * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
318
- * workflow-sidecar.ts:908 for the same class of shared-state mutation atomic `fs.mkdirSync`
319
- * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
320
- * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
321
- * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
322
- * as a small LOCAL helper (not a cross-import of that private function, which would pull the
323
- * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
331
+ * races against the built CLI). Atomic directory creation establishes ownership before metadata
332
+ * is written; contenders treat even an ownerless directory as held. Live contention waits with a
333
+ * bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
334
+ * filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
324
335
  * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
325
336
  * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
326
337
  * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
@@ -331,22 +342,31 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
331
342
  */
332
343
  export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
333
344
  const lockDir = subjectLockDir(artifactRoot, subjectId);
334
- const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
345
+ const staleMs = trustedSubjectLockStaleMs();
346
+ const token = randomBytes(16).toString("hex");
347
+ const ownerFile = path.join(lockDir, "owner.json");
335
348
  const deadline = Date.now() + 30000;
336
349
  while (true) {
350
+ let createdLockDir = false;
337
351
  try {
338
352
  fs.mkdirSync(lockDir);
353
+ createdLockDir = true;
354
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
339
355
  break;
340
356
  } catch (error) {
341
357
  const lockError = error as NodeJS.ErrnoException;
358
+ if (createdLockDir) fs.rmSync(lockDir, { recursive: true, force: true });
342
359
  if (lockError.code !== "EEXIST") {
343
360
  throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
344
361
  }
345
362
  try {
346
- const stat = fs.statSync(lockDir);
347
- if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
348
- fs.rmSync(lockDir, { recursive: true, force: true });
349
- continue;
363
+ const owner = readSubjectLockOwner(ownerFile);
364
+ const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
365
+ if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
366
+ throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
367
+ }
368
+ if (Date.now() - stat.mtimeMs > staleMs) {
369
+ throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
350
370
  }
351
371
  } catch (statError) {
352
372
  if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
@@ -358,7 +378,12 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
358
378
  sleepSync(20);
359
379
  }
360
380
  }
361
- const release = (): void => fs.rmSync(lockDir, { recursive: true, force: true });
381
+ let heartbeat: NodeJS.Timeout | undefined;
382
+ const ownsLock = (): boolean => readSubjectLockOwner(ownerFile)?.token === token;
383
+ const release = (): void => {
384
+ if (heartbeat) clearInterval(heartbeat);
385
+ if (ownsLock()) fs.rmSync(lockDir, { recursive: true, force: true });
386
+ };
362
387
  let result: T;
363
388
  try {
364
389
  result = body();
@@ -367,12 +392,36 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
367
392
  throw error;
368
393
  }
369
394
  if (result && typeof (result as { then?: unknown }).then === "function") {
395
+ // An async owner can legitimately hold the lock longer than the stale-lock
396
+ // threshold while an authority-bound command is running. Keep its mtime fresh
397
+ // so lifecycle operations and takeovers continue to observe the live lock.
398
+ const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
399
+ heartbeat = setInterval(() => {
400
+ try {
401
+ if (!ownsLock()) return;
402
+ const timestamp = new Date();
403
+ fs.utimesSync(ownerFile, timestamp, timestamp);
404
+ fs.utimesSync(lockDir, timestamp, timestamp);
405
+ } catch { /* release, reclamation, or process teardown owns cleanup */ }
406
+ }, heartbeatMs);
370
407
  return Promise.resolve(result).finally(release) as T;
371
408
  }
372
409
  release();
373
410
  return result;
374
411
  }
375
412
 
413
+ function readSubjectLockOwner(file: string): { token?: string } | null {
414
+ try {
415
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
416
+ return value && typeof value === "object" && !Array.isArray(value)
417
+ ? value as { token?: string }
418
+ : null;
419
+ } catch (error) {
420
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null;
421
+ throw error;
422
+ }
423
+ }
424
+
376
425
  /**
377
426
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
378
427
  * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
@@ -726,7 +775,19 @@ export function performLocalReleaseUnderLock(
726
775
  // seam, relocated to this write path).
727
776
  const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
728
777
  const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
729
- if (holderActorKey !== releasedByActorKey) {
778
+ // Pre-3.7 lifecycle events could persist the derived ancestry actor before
779
+ // sanitizeSegment removed ':' separators. Modern explicit/env release paths
780
+ // always use the sanitized form. Accept only that one-way legacy migration;
781
+ // never normalize two modern keys or relax ownership to a prefix match.
782
+ const sameActorStruct = existing.actor.runtime === releasedBy.runtime
783
+ && existing.actor.session_id === releasedBy.session_id
784
+ && existing.actor.host === releasedBy.host
785
+ && (existing.actor.human ?? null) === (releasedBy.human ?? null);
786
+ const legacyActorKeyMatches = holderActorKey.includes(":")
787
+ && holderActorKey === helper.serializeActor(existing.actor)
788
+ && helper.sanitizeSegment(holderActorKey) === releasedByActorKey
789
+ && sameActorStruct;
790
+ if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
730
791
  if (tolerateNoActiveClaim) return null;
731
792
  throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
732
793
  }
@@ -17,7 +17,7 @@ import {
17
17
  BUILDER_BUILD_FLOW_ID,
18
18
  evaluateBuilderBuildRun,
19
19
  startBuilderBuildRun,
20
- } from "../../build/src/index.js";
20
+ } from "../../build/src/builder-flow-run-adapter.js";
21
21
 
22
22
  const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
23
23
  const BUILDER_BUILD_DEFINITION = path.join(REPO_ROOT, "kits/builder/flows/build.flow.json");
@@ -245,6 +245,8 @@ test("all verified parent selectors advance only their persisted gates through p
245
245
  subjectType: "flow-step",
246
246
  name: "verify-all-selectors",
247
247
  bundle: trustBundle({ claims: [
248
+ claim("workflow.critique.review", "workflow-critique"),
249
+ claim("workflow.acceptance.criterion", "flow-step"),
248
250
  claim("builder.verify.tests", "flow-step"),
249
251
  claim("builder.verify.policy-compliance", "artifact"),
250
252
  ] }),
@@ -271,7 +273,7 @@ test("all verified parent selectors advance only their persisted gates through p
271
273
  { gate_id: "design-probe-gate", status: "pass", matched_expectations: ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"] },
272
274
  { gate_id: "plan-gate", status: "pass", matched_expectations: ["implementation-plan"] },
273
275
  { gate_id: "execute-gate", status: "pass", matched_expectations: ["implementation-scope"] },
274
- { gate_id: "verify-gate", status: "pass", matched_expectations: ["tests-evidence", "policy-compliance"] },
276
+ { gate_id: "verify-gate", status: "pass", matched_expectations: ["clean-critique", "acceptance-criteria", "tests-evidence", "policy-compliance"] },
275
277
  { gate_id: "merge-ready-gate", status: "pass", matched_expectations: ["merge-readiness"] },
276
278
  ]);
277
279
  });