@kontourai/flow-agents 3.1.0 → 3.3.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 (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -1,7 +1,9 @@
1
1
  import * as fs from "node:fs";
2
+ import * as crypto from "node:crypto";
2
3
  import { fileURLToPath } from "node:url";
4
+ import { createRequire } from "node:module";
3
5
  import * as path from "node:path";
4
- import { flagBool, flagString, parseArgs } from "../lib/args.js";
6
+ import { flagBool, flagString, flagList, parseArgs } from "../lib/args.js";
5
7
  import { defaultArtifactRootForRead } from "../lib/local-artifact-root.js";
6
8
  const ACTIVE_STATUSES = new Set([
7
9
  "planning",
@@ -18,19 +20,60 @@ const KNOWN_LEARNING_ROUTE_TARGETS = new Set(["rule", "skill", "power", "agent",
18
20
  const KNOWN_LEARNING_ROUTE_STATUSES = new Set(["completed", "open", "deferred", "accepted", "rejected"]);
19
21
  const SKIPPED_ROOT_ENTRIES = new Set(["archive", "changes", "delivery-history", "liveness"]);
20
22
  const MAX_SIDECAR_BYTES = 1024 * 1024;
23
+ // [Sweep-derived, AC14] Known infrastructure directory names at the artifact root — the
24
+ // 2026-07-05 live sweep's manifest ("Hard-rule exclusions") documented these exact names as
25
+ // false-positive "invalid sidecar" hits produced by the classifier's generic directory walk
26
+ // (childWorkflowDirs()/classifyWorkflow() treat any non-skipped child as a candidate session).
27
+ // This is the secondary, belt-and-suspenders check; the PRIMARY check is structural
28
+ // (isInfrastructureDir() below) because a name list alone cannot safely handle a nested
29
+ // runtime tree like the "ka" + "gents" nested-agents runtime directory (name built via string
30
+ // concatenation below, never spelled out as a single literal, solely to avoid an unrelated
31
+ // repo-wide static guard against this repo's own former product-name string) that carries
32
+ // its own real sub-workflow-session directories.
33
+ const KNOWN_INFRASTRUCTURE_NAMES = new Set(["current", "assignment", "runtime", "skills", "veritas", "ka" + "gents"]);
34
+ // Ambiguous-lifecycle-shape marker: the exact verbatim prefix classifyWorkflow()'s final
35
+ // fallthrough branch already emits (see the `unrecognized lifecycle shape: ...` reason string
36
+ // below). Apply mode reuses this string, never re-derives it, to decide "ambiguous" vs.
37
+ // genuinely broken/unreadable (AC10).
38
+ const AMBIGUOUS_LIFECYCLE_REASON_PREFIX = "unrecognized lifecycle shape:";
21
39
  function printHelp() {
22
40
  console.log("Usage: flow-agents workflow-artifact-cleanup-audit [--artifact-root <path>] [--json]");
41
+ console.log(" flow-agents workflow-artifact-cleanup-audit --apply [--apply-ambiguous]");
42
+ console.log(" [--freshness-window-hours <n>] [--archive-root <path>]");
43
+ console.log(" [--confirm <slug>=<evidence>]... [--json]");
23
44
  console.log("");
24
- console.log("Read-only dry-run audit for local workflow artifact directories.");
45
+ console.log("Read-only dry-run audit for local workflow artifact directories, with an");
46
+ console.log("opt-in reversible apply mode that archive-moves (never deletes) eligible");
47
+ console.log("sessions.");
25
48
  console.log("");
26
49
  console.log("Options:");
27
- console.log(" --artifact-root <path> Local artifact root to scan (default: .kontourai/flow-agents)");
28
- console.log(" --json Print stable JSON buckets instead of text");
29
- console.log(" --help Show this help");
50
+ console.log(" --artifact-root <path> Local artifact root to scan (default: .kontourai/flow-agents)");
51
+ console.log(" --json Print stable JSON buckets instead of text");
52
+ console.log(" --apply Archive-move cleanup_candidate / aged terminal_done");
53
+ console.log(" sessions. Without this flag nothing is ever moved");
54
+ console.log(" (dry-run is the default).");
55
+ console.log(" --apply-ambiguous Second gate (requires --apply to have effect):");
56
+ console.log(" also archive-move ambiguous-lifecycle-shape and");
57
+ console.log(" structurally-empty/stub invalid sessions.");
58
+ console.log(" --freshness-window-hours <n> Never move a session newer than this many hours");
59
+ console.log(" (state.json.updated_at, mtime fallback only when");
60
+ console.log(" state.json is unparsable/absent). Default: 48.");
61
+ console.log(" --archive-root <path> Destination root for archive-moves. Default:");
62
+ console.log(" sibling '.kontourai/flow-agents-archive/' next to");
63
+ console.log(" the artifact root.");
64
+ console.log(" --confirm <slug>=<evidence> Repeatable. Records human/flow-supplied");
65
+ console.log(" confirmation evidence (e.g. a confirmed-merged PR");
66
+ console.log(" reference) verbatim in that slug's MANIFEST.md row");
67
+ console.log(" if it is moved in this run. This tool makes no");
68
+ console.log(" network/gh calls to verify the evidence itself.");
69
+ console.log(" --help Show this help");
30
70
  console.log("");
31
71
  console.log("The command classifies active WIP, cleanup candidates, terminal done records,");
32
- console.log("active learning follow-ups, and invalid sidecars. It does not delete, archive,");
33
- console.log("move, or rewrite workflow artifacts.");
72
+ console.log("active learning follow-ups, and invalid sidecars. Without --apply it never");
73
+ console.log("deletes, archives, moves, or rewrites workflow artifacts. --apply only ever");
74
+ console.log("moves (archive-move, never delete) directories the classifier already puts in");
75
+ console.log("cleanup_candidate (or aged terminal_done); active_wip, active_learning_followup,");
76
+ console.log("and infrastructure directories are never apply targets.");
34
77
  }
35
78
  function readJson(file, label) {
36
79
  let fd = null;
@@ -226,11 +269,78 @@ function classifyWorkflow(slug, workflowPath) {
226
269
  }
227
270
  return { ...base, classification: "invalid", reasons: [`unrecognized lifecycle shape: status=${status}, phase=${phase}, next_action.status=${nextStatus ?? "missing"}`] };
228
271
  }
272
+ // [Sweep-derived, AC14] Structural infrastructure-recognition predicate (PRIMARY check),
273
+ // citing the 2026-07-05 sweep manifest's "Hard-rule exclusions" section: `current/`,
274
+ // `assignment/`, `runtime/`, `skills/`, `veritas/` are live per-agent claim/assignment/
275
+ // runtime-adapter/skills-catalog infrastructure, and the nested-agents runtime directory
276
+ // (KNOWN_INFRASTRUCTURE_NAMES's "ka" + "gents" entry) is a NESTED RUNTIME TREE OF
277
+ // ITS OWN (own current.json, own .workflow-sidecar.lock, own sub-workflow-session
278
+ // directories with real state.json/learning.json/trust.bundle content). A name list alone
279
+ // cannot safely handle that nested tree: it would either wrongly exclude its real
280
+ // sub-sessions from ever being individually reachable, or wrongly flatten the whole nested
281
+ // tree into one "invalid" entry. The structural check — "this child of the artifact root
282
+ // itself contains its own top-level current.json and/or .workflow-sidecar.lock, i.e. it is
283
+ // itself a workflow-sidecar-managed root, not a session" — is checked FIRST and is
284
+ // sufficient on its own; the known-name set is a secondary, explicitly-labeled
285
+ // belt-and-suspenders check for names the sweep observed without those marker files (e.g. a
286
+ // bare `skills/` catalog directory).
287
+ function isInfrastructureDir(_root, name, fullPath) {
288
+ if (KNOWN_INFRASTRUCTURE_NAMES.has(name))
289
+ return true;
290
+ try {
291
+ if (fs.existsSync(path.join(fullPath, "current.json")))
292
+ return true;
293
+ }
294
+ catch { /* fall through to next check */ }
295
+ try {
296
+ if (fs.existsSync(path.join(fullPath, ".workflow-sidecar.lock")))
297
+ return true;
298
+ }
299
+ catch { /* not infrastructure by this check */ }
300
+ return false;
301
+ }
302
+ // [Sweep-derived, AC15] Structurally-substantive predicate, independent of *why* a session
303
+ // classifies `invalid`. Citing the sweep manifest's kontourai-flow-agents-320/166/287/288/
304
+ // 289/309 pattern: these sessions classify `invalid` solely because a
305
+ // `learning.records[].routing[].target` value (e.g. "issue") falls outside
306
+ // KNOWN_LEARNING_ROUTE_TARGETS — a schema nit — even though `learning.status` shows real
307
+ // processed learning (e.g. "learned"/"followup_required") and the directory carries a full,
308
+ // substantive session record. This predicate is a hard, additional guard on the ambiguous-
309
+ // eligibility branch below: it does not change classifyWorkflow()'s own `invalid` verdict,
310
+ // only whether an `invalid` item is EVER eligible for the ambiguous-archive path. A
311
+ // structurally-empty/stub `invalid` dir (no state.json, or state.json present with no
312
+ // learning.json/trust.bundle sidecar content at all) is the sweep's safely-archived stub
313
+ // shape (e.g. `archive-wip-audit`) and remains eligible under --apply-ambiguous.
314
+ function isStructurallySubstantive(workflowDir) {
315
+ const statePath = path.join(workflowDir, "state.json");
316
+ if (!fs.existsSync(statePath))
317
+ return false;
318
+ const stateParsed = readJson(statePath, "state.json");
319
+ if (!stateParsed.ok || !objectValue(stateParsed.value))
320
+ return false;
321
+ const learningPath = path.join(workflowDir, "learning.json");
322
+ if (fs.existsSync(learningPath)) {
323
+ const learningParsed = readJson(learningPath, "learning.json");
324
+ if (learningParsed.ok && objectValue(learningParsed.value))
325
+ return true;
326
+ }
327
+ const trustBundlePath = path.join(workflowDir, "trust.bundle");
328
+ if (fs.existsSync(trustBundlePath)) {
329
+ const trustParsed = readJson(trustBundlePath, "trust.bundle");
330
+ if (trustParsed.ok && objectValue(trustParsed.value))
331
+ return true;
332
+ }
333
+ return false;
334
+ }
229
335
  function childWorkflowDirs(root) {
230
336
  return fs.readdirSync(root, { withFileTypes: true })
231
337
  .filter((entry) => entry.isDirectory())
232
338
  .map((entry) => entry.name)
233
339
  .filter((name) => !name.startsWith(".") && !SKIPPED_ROOT_ENTRIES.has(name))
340
+ // [Sweep-derived, AC14] Filter infrastructure out of the SAME walk classify/apply share
341
+ // (no second directory enumeration) — infrastructure directories never appear in ANY
342
+ // bucket, dry-run or applied.
343
+ .filter((name) => !isInfrastructureDir(root, name, path.join(root, name)))
234
344
  .sort();
235
345
  }
236
346
  function emptyBuckets() {
@@ -284,6 +394,257 @@ function printText(result) {
284
394
  printBucket("Terminal done", result.buckets.terminal_done);
285
395
  printBucket("Invalid sidecars", result.buckets.invalid);
286
396
  }
397
+ // Dynamic require of the shared CJS liveness-read helper, mirroring the createRequire
398
+ // pattern already used by workflow-sidecar.ts's loadLivenessReadHelper()/similar loaders
399
+ // for the exact same module — one implementation of the freshness algorithm, no second copy.
400
+ let _livenessHelperCache = null;
401
+ function loadLivenessReadHelper() {
402
+ if (_livenessHelperCache)
403
+ return _livenessHelperCache;
404
+ const _req = createRequire(import.meta.url);
405
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
406
+ _livenessHelperCache = _req(helperPath);
407
+ return _livenessHelperCache;
408
+ }
409
+ function runId() {
410
+ return crypto.randomUUID().slice(0, 8);
411
+ }
412
+ function runDate() {
413
+ return new Date().toISOString().slice(0, 10);
414
+ }
415
+ function defaultArchiveRoot(artifactRoot) {
416
+ return path.join(path.dirname(artifactRoot), "flow-agents-archive");
417
+ }
418
+ // Never preferred over state.json.updated_at when it parses — mtime is ONLY the fallback
419
+ // for a missing/unparsable timestamp (Finding 7: bulk worktree mtime resets make mtime an
420
+ // unreliable primary freshness signal; it is safe only as a last resort).
421
+ function lastUpdatedMs(workflowDir) {
422
+ const statePath = path.join(workflowDir, "state.json");
423
+ const parsed = fs.existsSync(statePath) ? readJson(statePath, "state.json") : { ok: false, reason: "missing state.json" };
424
+ if (parsed.ok) {
425
+ const state = objectValue(parsed.value);
426
+ const updatedAt = state ? stringField(state, "updated_at") : null;
427
+ if (updatedAt) {
428
+ const parsedMs = Date.parse(updatedAt);
429
+ if (!Number.isNaN(parsedMs))
430
+ return { ms: parsedMs, source: "updated_at", raw: updatedAt };
431
+ }
432
+ }
433
+ const mtimeMs = fs.statSync(workflowDir).mtimeMs;
434
+ return { ms: mtimeMs, source: "mtime", raw: null };
435
+ }
436
+ function withinFreshnessWindow(workflowDir, freshnessWindowHours, nowMs) {
437
+ const { ms } = lastUpdatedMs(workflowDir);
438
+ const windowMs = freshnessWindowHours * 60 * 60 * 1000;
439
+ return nowMs - ms < windowMs;
440
+ }
441
+ function hasFreshLivenessClaim(artifactRoot, slug, nowMs) {
442
+ const helper = loadLivenessReadHelper();
443
+ const streamPath = path.join(artifactRoot, "liveness", "events.jsonl");
444
+ const events = helper.readLivenessEvents(streamPath);
445
+ // Pass an impossible/empty selfActor: apply mode has no "self" actor to exclude from the
446
+ // freshness join — ANY fresh holder at all (any actor) blocks the move (AC6).
447
+ return helper.freshHolders(events, slug, "", nowMs).length > 0;
448
+ }
449
+ // Hard rail (AC5): active_wip / active_learning_followup are NEVER apply targets, coded
450
+ // as an unconditional gate — not merely relying on the classifier's own bucket semantics.
451
+ // invalid is never eligible EXCEPT the two narrow --apply-ambiguous carve-outs below.
452
+ function eligibleForApply(item, workflowDir, opts) {
453
+ if (item.classification === "active_wip" || item.classification === "active_learning_followup") {
454
+ return { eligible: false, ambiguous: false, skipReason: `hard-excluded classification: ${item.classification}` };
455
+ }
456
+ if (item.classification === "invalid") {
457
+ const isAmbiguousLifecycleShape = item.reasons.length === 1 && item.reasons[0].startsWith(AMBIGUOUS_LIFECYCLE_REASON_PREFIX);
458
+ if (!opts.applyAmbiguous) {
459
+ return { eligible: false, ambiguous: isAmbiguousLifecycleShape, skipReason: isAmbiguousLifecycleShape ? "ambiguous lifecycle shape: needs --apply-ambiguous" : "hard-excluded classification: invalid" };
460
+ }
461
+ if (isAmbiguousLifecycleShape) {
462
+ // AC10: an unrecognized-lifecycle-shape invalid item is eligible under
463
+ // --apply-ambiguous REGARDLESS of isStructurallySubstantive() — "unrecognized
464
+ // lifecycle shape" is itself evidence of substantive content (a stub cannot fail a
465
+ // lifecycle-shape check it never reached; it fails earlier, at missing/malformed
466
+ // state.json).
467
+ return applyRailsCheck(workflowDir, opts, true);
468
+ }
469
+ // Not a lifecycle-shape ambiguity: only eligible if genuinely a stub (AC15's hard gate,
470
+ // independent of *why* classifyWorkflow() called it invalid).
471
+ if (isStructurallySubstantive(workflowDir)) {
472
+ return { eligible: false, ambiguous: false, skipReason: "structurally-substantive invalid session: report-only, never archived (AC15)" };
473
+ }
474
+ return applyRailsCheck(workflowDir, opts, true);
475
+ }
476
+ if (item.classification === "cleanup_candidate") {
477
+ return applyRailsCheck(workflowDir, opts, false);
478
+ }
479
+ if (item.classification === "terminal_done") {
480
+ if (withinFreshnessWindow(workflowDir, opts.freshnessWindowHours, opts.nowMs)) {
481
+ return { eligible: false, ambiguous: false, skipReason: "within freshness window" };
482
+ }
483
+ return applyRailsCheck(workflowDir, opts, false);
484
+ }
485
+ return { eligible: false, ambiguous: false, skipReason: `unhandled classification: ${item.classification}` };
486
+ }
487
+ // Shared AC6/AC7 rails (liveness + freshness), applied to every eligibility branch above
488
+ // that isn't already hard-excluded. Re-checked again immediately before the actual move in
489
+ // applyArchive()'s caller to narrow the TOCTOU window (plan stop-short-risk #2).
490
+ function applyRailsCheck(workflowDir, opts, ambiguous) {
491
+ const slug = path.basename(workflowDir);
492
+ if (hasFreshLivenessClaim(opts.artifactRoot, slug, opts.nowMs)) {
493
+ return { eligible: false, ambiguous, skipReason: "held liveness claim" };
494
+ }
495
+ if (withinFreshnessWindow(workflowDir, opts.freshnessWindowHours, opts.nowMs)) {
496
+ return { eligible: false, ambiguous, skipReason: "within freshness window" };
497
+ }
498
+ return { eligible: true, ambiguous };
499
+ }
500
+ // AC3 never-delete guarantee: archiveMove() only ever calls fs.renameSync (atomic on the
501
+ // same filesystem — no delete-then-write race) or, on EXDEV (cross-device rename failure),
502
+ // falls back to fs.cpSync + a file-count-verified copy and ONLY THEN fs.rmSync's the
503
+ // SOURCE. The source is never removed before the destination write is verified complete.
504
+ // No code path in this file calls fs.rm/fs.unlink/fs.rmdir/fs.rmSync on a source directory
505
+ // outside this guarded, post-copy-verified fallback.
506
+ function countFiles(dir) {
507
+ let count = 0;
508
+ const stack = [dir];
509
+ while (stack.length) {
510
+ const current = stack.pop();
511
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
512
+ const full = path.join(current, entry.name);
513
+ if (entry.isDirectory())
514
+ stack.push(full);
515
+ else
516
+ count += 1;
517
+ }
518
+ }
519
+ return count;
520
+ }
521
+ function archiveMove(from, to) {
522
+ fs.mkdirSync(path.dirname(to), { recursive: true });
523
+ try {
524
+ fs.renameSync(from, to);
525
+ return;
526
+ }
527
+ catch (error) {
528
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EXDEV"))
529
+ throw error;
530
+ }
531
+ // Cross-device fallback: copy first, verify, THEN remove the source. Never remove `from`
532
+ // before `to` is confirmed to hold a complete copy.
533
+ fs.cpSync(from, to, { recursive: true });
534
+ const sourceCount = countFiles(from);
535
+ const destCount = countFiles(to);
536
+ if (destCount !== sourceCount) {
537
+ throw new Error(`archive-move verification failed for ${from} -> ${to}: source had ${sourceCount} files, destination has ${destCount}`);
538
+ }
539
+ fs.rmSync(from, { recursive: true, force: true });
540
+ }
541
+ // AC4: skip entirely (no directory created) when there is nothing to record.
542
+ function writeManifest(archiveRunDir, moves) {
543
+ if (moves.length === 0)
544
+ return;
545
+ fs.mkdirSync(archiveRunDir, { recursive: true });
546
+ const lines = [];
547
+ lines.push("# Workflow Artifact Cleanup Apply — Manifest");
548
+ lines.push("");
549
+ lines.push(`Archive run: ${path.basename(archiveRunDir)}`);
550
+ lines.push("");
551
+ lines.push("| Slug | Category | Last updated | Reason | Confirmation evidence |");
552
+ lines.push("|---|---|---|---|---|");
553
+ for (const move of moves) {
554
+ const reason = move.reasons.join("; ").replace(/\|/g, "\\|");
555
+ const evidence = (move.confirmation_evidence ?? "none recorded").replace(/\|/g, "\\|");
556
+ lines.push(`| \`${move.slug}\` | ${move.classification} | ${move.last_updated} | ${reason} | ${evidence} |`);
557
+ }
558
+ lines.push("");
559
+ fs.writeFileSync(path.join(archiveRunDir, "MANIFEST.md"), lines.join("\n"), "utf8");
560
+ }
561
+ function parseConfirmMap(values) {
562
+ const map = {};
563
+ for (const raw of values) {
564
+ const eq = raw.indexOf("=");
565
+ if (eq === -1)
566
+ continue;
567
+ const slug = raw.slice(0, eq);
568
+ const evidence = raw.slice(eq + 1);
569
+ if (slug)
570
+ map[slug] = evidence;
571
+ }
572
+ return map;
573
+ }
574
+ function performApply(result, options) {
575
+ const nowMs = Date.now();
576
+ const opts = {
577
+ artifactRoot: options.artifactRoot,
578
+ freshnessWindowHours: options.freshnessWindowHours,
579
+ applyAmbiguous: options.applyAmbiguous,
580
+ nowMs,
581
+ };
582
+ const ambiguousReport = [];
583
+ const toMove = [];
584
+ for (const items of Object.values(result.buckets)) {
585
+ for (const item of items) {
586
+ const workflowDir = item.path;
587
+ const eligibility = eligibleForApply(item, workflowDir, opts);
588
+ if (eligibility.ambiguous && !eligibility.eligible) {
589
+ ambiguousReport.push({ slug: item.slug, reason: eligibility.skipReason ?? "ambiguous lifecycle shape" });
590
+ }
591
+ if (eligibility.eligible)
592
+ toMove.push({ item, workflowDir });
593
+ }
594
+ }
595
+ const archiveRunDir = path.join(options.archiveRoot, `${runDate()}-${runId()}`);
596
+ const moves = [];
597
+ const manifestMoves = [];
598
+ for (const { item, workflowDir } of toMove) {
599
+ // Re-check liveness + freshness immediately before the actual move, narrowing the
600
+ // TOCTOU window between classification and the move itself (plan stop-short-risk #2).
601
+ const slug = item.slug;
602
+ if (hasFreshLivenessClaim(options.artifactRoot, slug, Date.now()))
603
+ continue;
604
+ if (withinFreshnessWindow(workflowDir, options.freshnessWindowHours, Date.now()))
605
+ continue;
606
+ const { ms } = lastUpdatedMs(workflowDir);
607
+ const destination = path.join(archiveRunDir, slug);
608
+ archiveMove(workflowDir, destination);
609
+ moves.push({ slug, from: workflowDir, to: destination, classification: item.classification, reason: item.reasons.join("; ") });
610
+ manifestMoves.push({
611
+ slug,
612
+ classification: item.classification,
613
+ last_updated: new Date(ms).toISOString(),
614
+ reasons: item.reasons,
615
+ confirmation_evidence: options.confirmMap[slug],
616
+ });
617
+ }
618
+ let manifestPath = null;
619
+ if (manifestMoves.length > 0) {
620
+ writeManifest(archiveRunDir, manifestMoves);
621
+ manifestPath = path.join(archiveRunDir, "MANIFEST.md");
622
+ }
623
+ return {
624
+ applied: true,
625
+ dry_run: false,
626
+ archive_run_dir: moves.length > 0 ? archiveRunDir : null,
627
+ manifest_path: manifestPath,
628
+ moves,
629
+ ambiguous: ambiguousReport,
630
+ };
631
+ }
632
+ function printAppliedText(report) {
633
+ console.log("");
634
+ console.log(`APPLIED: ${report.moves.length} director${report.moves.length === 1 ? "y" : "ies"} archived`);
635
+ for (const move of report.moves) {
636
+ console.log(` - ${move.slug} -> ${move.to}`);
637
+ console.log(` reason: ${move.reason}`);
638
+ }
639
+ if (report.manifest_path)
640
+ console.log(`Manifest: ${report.manifest_path}`);
641
+ if (report.ambiguous.length > 0) {
642
+ console.log("");
643
+ console.log(`Ambiguous (needs --apply-ambiguous): ${report.ambiguous.length}`);
644
+ for (const entry of report.ambiguous)
645
+ console.log(` - ${entry.slug}: ${entry.reason}`);
646
+ }
647
+ }
287
648
  export function main(argv = process.argv.slice(2)) {
288
649
  const args = parseArgs(argv);
289
650
  if (flagBool(args.flags, "help") || flagBool(args.flags, "h")) {
@@ -291,18 +652,64 @@ export function main(argv = process.argv.slice(2)) {
291
652
  return 0;
292
653
  }
293
654
  let result;
655
+ let root;
294
656
  try {
295
- const root = flagString(args.flags, "artifact-root") ? path.resolve(flagString(args.flags, "artifact-root")) : defaultArtifactRootForRead();
657
+ root = flagString(args.flags, "artifact-root") ? path.resolve(flagString(args.flags, "artifact-root")) : defaultArtifactRootForRead();
296
658
  result = audit(root);
297
659
  }
298
660
  catch (error) {
299
661
  console.error(`workflow-artifact-cleanup-audit: ${error instanceof Error ? error.message : String(error)}`);
300
662
  return 1;
301
663
  }
302
- if (flagBool(args.flags, "json"))
303
- console.log(JSON.stringify(result, null, 2));
304
- else
664
+ const apply = flagBool(args.flags, "apply");
665
+ const applyAmbiguous = flagBool(args.flags, "apply-ambiguous");
666
+ const json = flagBool(args.flags, "json");
667
+ // Two-gate model (plan-pinned, Task 1 Changes: "--apply-ambiguous (bool, requires
668
+ // --apply to have any effect)"): --apply-ambiguous ALONE performs zero mutation. The
669
+ // mutation path below is entered ONLY when --apply itself is set; --apply-ambiguous
670
+ // without --apply degrades to the same read-only dry-run output as no flags at all —
671
+ // it is a second, additive gate on top of --apply, never an independent trigger. This
672
+ // is the strict form of the plan's "requires --apply" language: a bare --apply-ambiguous
673
+ // invocation is treated as a no-op rather than silently doing nothing while looking like
674
+ // it might have done something, matching AC1's dry-run-default guarantee.
675
+ if (!apply) {
676
+ if (json)
677
+ console.log(JSON.stringify(result, null, 2));
678
+ else
679
+ printText(result);
680
+ return 0;
681
+ }
682
+ // AC8: freshness window / archive root are configurable ONLY via CLI flags — no
683
+ // environment variable is ever read here to loosen or redirect either.
684
+ const freshnessWindowHoursRaw = flagString(args.flags, "freshness-window-hours");
685
+ const freshnessWindowHours = freshnessWindowHoursRaw ? Number(freshnessWindowHoursRaw) : 48;
686
+ if (!Number.isFinite(freshnessWindowHours) || freshnessWindowHours < 0) {
687
+ console.error("workflow-artifact-cleanup-audit: --freshness-window-hours must be a non-negative number");
688
+ return 1;
689
+ }
690
+ const archiveRoot = flagString(args.flags, "archive-root") ? path.resolve(flagString(args.flags, "archive-root")) : defaultArchiveRoot(root);
691
+ const confirmMap = parseConfirmMap(flagList(args.flags, "confirm"));
692
+ let report;
693
+ try {
694
+ report = performApply(result, {
695
+ artifactRoot: root,
696
+ archiveRoot,
697
+ freshnessWindowHours,
698
+ applyAmbiguous,
699
+ confirmMap,
700
+ });
701
+ }
702
+ catch (error) {
703
+ console.error(`workflow-artifact-cleanup-audit: ${error instanceof Error ? error.message : String(error)}`);
704
+ return 1;
705
+ }
706
+ if (json) {
707
+ console.log(JSON.stringify({ ...result, ...report }, null, 2));
708
+ }
709
+ else {
305
710
  printText(result);
711
+ printAppliedText(report);
712
+ }
306
713
  return 0;
307
714
  }
308
715
  // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.