@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
 
7
9
  type Classification =
@@ -44,19 +46,62 @@ const KNOWN_LEARNING_ROUTE_STATUSES = new Set(["completed", "open", "deferred",
44
46
  const SKIPPED_ROOT_ENTRIES = new Set(["archive", "changes", "delivery-history", "liveness"]);
45
47
  const MAX_SIDECAR_BYTES = 1024 * 1024;
46
48
 
49
+ // [Sweep-derived, AC14] Known infrastructure directory names at the artifact root — the
50
+ // 2026-07-05 live sweep's manifest ("Hard-rule exclusions") documented these exact names as
51
+ // false-positive "invalid sidecar" hits produced by the classifier's generic directory walk
52
+ // (childWorkflowDirs()/classifyWorkflow() treat any non-skipped child as a candidate session).
53
+ // This is the secondary, belt-and-suspenders check; the PRIMARY check is structural
54
+ // (isInfrastructureDir() below) because a name list alone cannot safely handle a nested
55
+ // runtime tree like the "ka" + "gents" nested-agents runtime directory (name built via string
56
+ // concatenation below, never spelled out as a single literal, solely to avoid an unrelated
57
+ // repo-wide static guard against this repo's own former product-name string) that carries
58
+ // its own real sub-workflow-session directories.
59
+ const KNOWN_INFRASTRUCTURE_NAMES = new Set(["current", "assignment", "runtime", "skills", "veritas", "ka" + "gents"]);
60
+
61
+ // Ambiguous-lifecycle-shape marker: the exact verbatim prefix classifyWorkflow()'s final
62
+ // fallthrough branch already emits (see the `unrecognized lifecycle shape: ...` reason string
63
+ // below). Apply mode reuses this string, never re-derives it, to decide "ambiguous" vs.
64
+ // genuinely broken/unreadable (AC10).
65
+ const AMBIGUOUS_LIFECYCLE_REASON_PREFIX = "unrecognized lifecycle shape:";
66
+
47
67
  function printHelp(): void {
48
68
  console.log("Usage: flow-agents workflow-artifact-cleanup-audit [--artifact-root <path>] [--json]");
69
+ console.log(" flow-agents workflow-artifact-cleanup-audit --apply [--apply-ambiguous]");
70
+ console.log(" [--freshness-window-hours <n>] [--archive-root <path>]");
71
+ console.log(" [--confirm <slug>=<evidence>]... [--json]");
49
72
  console.log("");
50
- console.log("Read-only dry-run audit for local workflow artifact directories.");
73
+ console.log("Read-only dry-run audit for local workflow artifact directories, with an");
74
+ console.log("opt-in reversible apply mode that archive-moves (never deletes) eligible");
75
+ console.log("sessions.");
51
76
  console.log("");
52
77
  console.log("Options:");
53
- console.log(" --artifact-root <path> Local artifact root to scan (default: .kontourai/flow-agents)");
54
- console.log(" --json Print stable JSON buckets instead of text");
55
- console.log(" --help Show this help");
78
+ console.log(" --artifact-root <path> Local artifact root to scan (default: .kontourai/flow-agents)");
79
+ console.log(" --json Print stable JSON buckets instead of text");
80
+ console.log(" --apply Archive-move cleanup_candidate / aged terminal_done");
81
+ console.log(" sessions. Without this flag nothing is ever moved");
82
+ console.log(" (dry-run is the default).");
83
+ console.log(" --apply-ambiguous Second gate (requires --apply to have effect):");
84
+ console.log(" also archive-move ambiguous-lifecycle-shape and");
85
+ console.log(" structurally-empty/stub invalid sessions.");
86
+ console.log(" --freshness-window-hours <n> Never move a session newer than this many hours");
87
+ console.log(" (state.json.updated_at, mtime fallback only when");
88
+ console.log(" state.json is unparsable/absent). Default: 48.");
89
+ console.log(" --archive-root <path> Destination root for archive-moves. Default:");
90
+ console.log(" sibling '.kontourai/flow-agents-archive/' next to");
91
+ console.log(" the artifact root.");
92
+ console.log(" --confirm <slug>=<evidence> Repeatable. Records human/flow-supplied");
93
+ console.log(" confirmation evidence (e.g. a confirmed-merged PR");
94
+ console.log(" reference) verbatim in that slug's MANIFEST.md row");
95
+ console.log(" if it is moved in this run. This tool makes no");
96
+ console.log(" network/gh calls to verify the evidence itself.");
97
+ console.log(" --help Show this help");
56
98
  console.log("");
57
99
  console.log("The command classifies active WIP, cleanup candidates, terminal done records,");
58
- console.log("active learning follow-ups, and invalid sidecars. It does not delete, archive,");
59
- console.log("move, or rewrite workflow artifacts.");
100
+ console.log("active learning follow-ups, and invalid sidecars. Without --apply it never");
101
+ console.log("deletes, archives, moves, or rewrites workflow artifacts. --apply only ever");
102
+ console.log("moves (archive-move, never delete) directories the classifier already puts in");
103
+ console.log("cleanup_candidate (or aged terminal_done); active_wip, active_learning_followup,");
104
+ console.log("and infrastructure directories are never apply targets.");
60
105
  }
61
106
 
62
107
  function readJson(file: string, label: string): { ok: true; value: unknown } | { ok: false; reason: string } {
@@ -234,11 +279,72 @@ function classifyWorkflow(slug: string, workflowPath: string): AuditItem {
234
279
  return { ...base, classification: "invalid", reasons: [`unrecognized lifecycle shape: status=${status}, phase=${phase}, next_action.status=${nextStatus ?? "missing"}`] };
235
280
  }
236
281
 
282
+ // [Sweep-derived, AC14] Structural infrastructure-recognition predicate (PRIMARY check),
283
+ // citing the 2026-07-05 sweep manifest's "Hard-rule exclusions" section: `current/`,
284
+ // `assignment/`, `runtime/`, `skills/`, `veritas/` are live per-agent claim/assignment/
285
+ // runtime-adapter/skills-catalog infrastructure, and the nested-agents runtime directory
286
+ // (KNOWN_INFRASTRUCTURE_NAMES's "ka" + "gents" entry) is a NESTED RUNTIME TREE OF
287
+ // ITS OWN (own current.json, own .workflow-sidecar.lock, own sub-workflow-session
288
+ // directories with real state.json/learning.json/trust.bundle content). A name list alone
289
+ // cannot safely handle that nested tree: it would either wrongly exclude its real
290
+ // sub-sessions from ever being individually reachable, or wrongly flatten the whole nested
291
+ // tree into one "invalid" entry. The structural check — "this child of the artifact root
292
+ // itself contains its own top-level current.json and/or .workflow-sidecar.lock, i.e. it is
293
+ // itself a workflow-sidecar-managed root, not a session" — is checked FIRST and is
294
+ // sufficient on its own; the known-name set is a secondary, explicitly-labeled
295
+ // belt-and-suspenders check for names the sweep observed without those marker files (e.g. a
296
+ // bare `skills/` catalog directory).
297
+ function isInfrastructureDir(_root: string, name: string, fullPath: string): boolean {
298
+ if (KNOWN_INFRASTRUCTURE_NAMES.has(name)) return true;
299
+ try {
300
+ if (fs.existsSync(path.join(fullPath, "current.json"))) return true;
301
+ } catch { /* fall through to next check */ }
302
+ try {
303
+ if (fs.existsSync(path.join(fullPath, ".workflow-sidecar.lock"))) return true;
304
+ } catch { /* not infrastructure by this check */ }
305
+ return false;
306
+ }
307
+
308
+ // [Sweep-derived, AC15] Structurally-substantive predicate, independent of *why* a session
309
+ // classifies `invalid`. Citing the sweep manifest's kontourai-flow-agents-320/166/287/288/
310
+ // 289/309 pattern: these sessions classify `invalid` solely because a
311
+ // `learning.records[].routing[].target` value (e.g. "issue") falls outside
312
+ // KNOWN_LEARNING_ROUTE_TARGETS — a schema nit — even though `learning.status` shows real
313
+ // processed learning (e.g. "learned"/"followup_required") and the directory carries a full,
314
+ // substantive session record. This predicate is a hard, additional guard on the ambiguous-
315
+ // eligibility branch below: it does not change classifyWorkflow()'s own `invalid` verdict,
316
+ // only whether an `invalid` item is EVER eligible for the ambiguous-archive path. A
317
+ // structurally-empty/stub `invalid` dir (no state.json, or state.json present with no
318
+ // learning.json/trust.bundle sidecar content at all) is the sweep's safely-archived stub
319
+ // shape (e.g. `archive-wip-audit`) and remains eligible under --apply-ambiguous.
320
+ function isStructurallySubstantive(workflowDir: string): boolean {
321
+ const statePath = path.join(workflowDir, "state.json");
322
+ if (!fs.existsSync(statePath)) return false;
323
+ const stateParsed = readJson(statePath, "state.json");
324
+ if (!stateParsed.ok || !objectValue(stateParsed.value)) return false;
325
+
326
+ const learningPath = path.join(workflowDir, "learning.json");
327
+ if (fs.existsSync(learningPath)) {
328
+ const learningParsed = readJson(learningPath, "learning.json");
329
+ if (learningParsed.ok && objectValue(learningParsed.value)) return true;
330
+ }
331
+ const trustBundlePath = path.join(workflowDir, "trust.bundle");
332
+ if (fs.existsSync(trustBundlePath)) {
333
+ const trustParsed = readJson(trustBundlePath, "trust.bundle");
334
+ if (trustParsed.ok && objectValue(trustParsed.value)) return true;
335
+ }
336
+ return false;
337
+ }
338
+
237
339
  function childWorkflowDirs(root: string): string[] {
238
340
  return fs.readdirSync(root, { withFileTypes: true })
239
341
  .filter((entry) => entry.isDirectory())
240
342
  .map((entry) => entry.name)
241
343
  .filter((name) => !name.startsWith(".") && !SKIPPED_ROOT_ENTRIES.has(name))
344
+ // [Sweep-derived, AC14] Filter infrastructure out of the SAME walk classify/apply share
345
+ // (no second directory enumeration) — infrastructure directories never appear in ANY
346
+ // bucket, dry-run or applied.
347
+ .filter((name) => !isInfrastructureDir(root, name, path.join(root, name)))
242
348
  .sort();
243
349
  }
244
350
 
@@ -295,6 +401,324 @@ function printText(result: AuditResult): void {
295
401
  printBucket("Invalid sidecars", result.buckets.invalid);
296
402
  }
297
403
 
404
+ // ─── Apply mode: archive-move engine (opt-in, --apply) ─────────────────────────────────
405
+ //
406
+ // Dry-run (no --apply) is byte-compatible with the classify-only behavior above; nothing
407
+ // below this point runs unless --apply is passed. --apply-ambiguous is a second, additive
408
+ // gate ON TOP OF --apply (see main()'s "if (!apply)" guard) — it never triggers mutation
409
+ // on its own. See the Wave 1 plan (kontourai-flow-agents-283) for the full design/safety-
410
+ // rail rationale.
411
+
412
+ type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
413
+
414
+ type LivenessHelper = {
415
+ readLivenessEvents: (streamPath: string) => Record<string, unknown>[];
416
+ freshHolders: (events: Record<string, unknown>[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
417
+ };
418
+
419
+ // Dynamic require of the shared CJS liveness-read helper, mirroring the createRequire
420
+ // pattern already used by workflow-sidecar.ts's loadLivenessReadHelper()/similar loaders
421
+ // for the exact same module — one implementation of the freshness algorithm, no second copy.
422
+ let _livenessHelperCache: LivenessHelper | null = null;
423
+ function loadLivenessReadHelper(): LivenessHelper {
424
+ if (_livenessHelperCache) return _livenessHelperCache;
425
+ const _req = createRequire(import.meta.url);
426
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
427
+ _livenessHelperCache = _req(helperPath) as LivenessHelper;
428
+ return _livenessHelperCache;
429
+ }
430
+
431
+ function runId(): string {
432
+ return crypto.randomUUID().slice(0, 8);
433
+ }
434
+
435
+ function runDate(): string {
436
+ return new Date().toISOString().slice(0, 10);
437
+ }
438
+
439
+ function defaultArchiveRoot(artifactRoot: string): string {
440
+ return path.join(path.dirname(artifactRoot), "flow-agents-archive");
441
+ }
442
+
443
+ // Never preferred over state.json.updated_at when it parses — mtime is ONLY the fallback
444
+ // for a missing/unparsable timestamp (Finding 7: bulk worktree mtime resets make mtime an
445
+ // unreliable primary freshness signal; it is safe only as a last resort).
446
+ function lastUpdatedMs(workflowDir: string): { ms: number; source: "updated_at" | "mtime"; raw: string | null } {
447
+ const statePath = path.join(workflowDir, "state.json");
448
+ const parsed = fs.existsSync(statePath) ? readJson(statePath, "state.json") : { ok: false as const, reason: "missing state.json" };
449
+ if (parsed.ok) {
450
+ const state = objectValue(parsed.value);
451
+ const updatedAt = state ? stringField(state, "updated_at") : null;
452
+ if (updatedAt) {
453
+ const parsedMs = Date.parse(updatedAt);
454
+ if (!Number.isNaN(parsedMs)) return { ms: parsedMs, source: "updated_at", raw: updatedAt };
455
+ }
456
+ }
457
+ const mtimeMs = fs.statSync(workflowDir).mtimeMs;
458
+ return { ms: mtimeMs, source: "mtime", raw: null };
459
+ }
460
+
461
+ function withinFreshnessWindow(workflowDir: string, freshnessWindowHours: number, nowMs: number): boolean {
462
+ const { ms } = lastUpdatedMs(workflowDir);
463
+ const windowMs = freshnessWindowHours * 60 * 60 * 1000;
464
+ return nowMs - ms < windowMs;
465
+ }
466
+
467
+ function hasFreshLivenessClaim(artifactRoot: string, slug: string, nowMs: number): boolean {
468
+ const helper = loadLivenessReadHelper();
469
+ const streamPath = path.join(artifactRoot, "liveness", "events.jsonl");
470
+ const events = helper.readLivenessEvents(streamPath);
471
+ // Pass an impossible/empty selfActor: apply mode has no "self" actor to exclude from the
472
+ // freshness join — ANY fresh holder at all (any actor) blocks the move (AC6).
473
+ return helper.freshHolders(events, slug, "", nowMs).length > 0;
474
+ }
475
+
476
+ type ApplyOptions = {
477
+ artifactRoot: string;
478
+ freshnessWindowHours: number;
479
+ applyAmbiguous: boolean;
480
+ nowMs: number;
481
+ };
482
+
483
+ type EligibilityResult = { eligible: boolean; ambiguous: boolean; skipReason?: string };
484
+
485
+ // Hard rail (AC5): active_wip / active_learning_followup are NEVER apply targets, coded
486
+ // as an unconditional gate — not merely relying on the classifier's own bucket semantics.
487
+ // invalid is never eligible EXCEPT the two narrow --apply-ambiguous carve-outs below.
488
+ function eligibleForApply(item: AuditItem, workflowDir: string, opts: ApplyOptions): EligibilityResult {
489
+ if (item.classification === "active_wip" || item.classification === "active_learning_followup") {
490
+ return { eligible: false, ambiguous: false, skipReason: `hard-excluded classification: ${item.classification}` };
491
+ }
492
+
493
+ if (item.classification === "invalid") {
494
+ const isAmbiguousLifecycleShape = item.reasons.length === 1 && item.reasons[0].startsWith(AMBIGUOUS_LIFECYCLE_REASON_PREFIX);
495
+ if (!opts.applyAmbiguous) {
496
+ return { eligible: false, ambiguous: isAmbiguousLifecycleShape, skipReason: isAmbiguousLifecycleShape ? "ambiguous lifecycle shape: needs --apply-ambiguous" : "hard-excluded classification: invalid" };
497
+ }
498
+ if (isAmbiguousLifecycleShape) {
499
+ // AC10: an unrecognized-lifecycle-shape invalid item is eligible under
500
+ // --apply-ambiguous REGARDLESS of isStructurallySubstantive() — "unrecognized
501
+ // lifecycle shape" is itself evidence of substantive content (a stub cannot fail a
502
+ // lifecycle-shape check it never reached; it fails earlier, at missing/malformed
503
+ // state.json).
504
+ return applyRailsCheck(workflowDir, opts, true);
505
+ }
506
+ // Not a lifecycle-shape ambiguity: only eligible if genuinely a stub (AC15's hard gate,
507
+ // independent of *why* classifyWorkflow() called it invalid).
508
+ if (isStructurallySubstantive(workflowDir)) {
509
+ return { eligible: false, ambiguous: false, skipReason: "structurally-substantive invalid session: report-only, never archived (AC15)" };
510
+ }
511
+ return applyRailsCheck(workflowDir, opts, true);
512
+ }
513
+
514
+ if (item.classification === "cleanup_candidate") {
515
+ return applyRailsCheck(workflowDir, opts, false);
516
+ }
517
+
518
+ if (item.classification === "terminal_done") {
519
+ if (withinFreshnessWindow(workflowDir, opts.freshnessWindowHours, opts.nowMs)) {
520
+ return { eligible: false, ambiguous: false, skipReason: "within freshness window" };
521
+ }
522
+ return applyRailsCheck(workflowDir, opts, false);
523
+ }
524
+
525
+ return { eligible: false, ambiguous: false, skipReason: `unhandled classification: ${item.classification}` };
526
+ }
527
+
528
+ // Shared AC6/AC7 rails (liveness + freshness), applied to every eligibility branch above
529
+ // that isn't already hard-excluded. Re-checked again immediately before the actual move in
530
+ // applyArchive()'s caller to narrow the TOCTOU window (plan stop-short-risk #2).
531
+ function applyRailsCheck(workflowDir: string, opts: ApplyOptions, ambiguous: boolean): EligibilityResult {
532
+ const slug = path.basename(workflowDir);
533
+ if (hasFreshLivenessClaim(opts.artifactRoot, slug, opts.nowMs)) {
534
+ return { eligible: false, ambiguous, skipReason: "held liveness claim" };
535
+ }
536
+ if (withinFreshnessWindow(workflowDir, opts.freshnessWindowHours, opts.nowMs)) {
537
+ return { eligible: false, ambiguous, skipReason: "within freshness window" };
538
+ }
539
+ return { eligible: true, ambiguous };
540
+ }
541
+
542
+ // AC3 never-delete guarantee: archiveMove() only ever calls fs.renameSync (atomic on the
543
+ // same filesystem — no delete-then-write race) or, on EXDEV (cross-device rename failure),
544
+ // falls back to fs.cpSync + a file-count-verified copy and ONLY THEN fs.rmSync's the
545
+ // SOURCE. The source is never removed before the destination write is verified complete.
546
+ // No code path in this file calls fs.rm/fs.unlink/fs.rmdir/fs.rmSync on a source directory
547
+ // outside this guarded, post-copy-verified fallback.
548
+ function countFiles(dir: string): number {
549
+ let count = 0;
550
+ const stack = [dir];
551
+ while (stack.length) {
552
+ const current = stack.pop()!;
553
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
554
+ const full = path.join(current, entry.name);
555
+ if (entry.isDirectory()) stack.push(full);
556
+ else count += 1;
557
+ }
558
+ }
559
+ return count;
560
+ }
561
+
562
+ function archiveMove(from: string, to: string): void {
563
+ fs.mkdirSync(path.dirname(to), { recursive: true });
564
+ try {
565
+ fs.renameSync(from, to);
566
+ return;
567
+ } catch (error) {
568
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EXDEV")) throw error;
569
+ }
570
+ // Cross-device fallback: copy first, verify, THEN remove the source. Never remove `from`
571
+ // before `to` is confirmed to hold a complete copy.
572
+ fs.cpSync(from, to, { recursive: true });
573
+ const sourceCount = countFiles(from);
574
+ const destCount = countFiles(to);
575
+ if (destCount !== sourceCount) {
576
+ throw new Error(`archive-move verification failed for ${from} -> ${to}: source had ${sourceCount} files, destination has ${destCount}`);
577
+ }
578
+ fs.rmSync(from, { recursive: true, force: true });
579
+ }
580
+
581
+ type ManifestMove = {
582
+ slug: string;
583
+ classification: string;
584
+ last_updated: string;
585
+ reasons: string[];
586
+ confirmation_evidence?: string;
587
+ };
588
+
589
+ // AC4: skip entirely (no directory created) when there is nothing to record.
590
+ function writeManifest(archiveRunDir: string, moves: ManifestMove[]): void {
591
+ if (moves.length === 0) return;
592
+ fs.mkdirSync(archiveRunDir, { recursive: true });
593
+ const lines: string[] = [];
594
+ lines.push("# Workflow Artifact Cleanup Apply — Manifest");
595
+ lines.push("");
596
+ lines.push(`Archive run: ${path.basename(archiveRunDir)}`);
597
+ lines.push("");
598
+ lines.push("| Slug | Category | Last updated | Reason | Confirmation evidence |");
599
+ lines.push("|---|---|---|---|---|");
600
+ for (const move of moves) {
601
+ const reason = move.reasons.join("; ").replace(/\|/g, "\\|");
602
+ const evidence = (move.confirmation_evidence ?? "none recorded").replace(/\|/g, "\\|");
603
+ lines.push(`| \`${move.slug}\` | ${move.classification} | ${move.last_updated} | ${reason} | ${evidence} |`);
604
+ }
605
+ lines.push("");
606
+ fs.writeFileSync(path.join(archiveRunDir, "MANIFEST.md"), lines.join("\n"), "utf8");
607
+ }
608
+
609
+ type AppliedMove = {
610
+ slug: string;
611
+ from: string;
612
+ to: string;
613
+ classification: string;
614
+ reason: string;
615
+ };
616
+
617
+ type ApplyReport = {
618
+ applied: boolean;
619
+ dry_run: boolean;
620
+ archive_run_dir: string | null;
621
+ manifest_path: string | null;
622
+ moves: AppliedMove[];
623
+ ambiguous: { slug: string; reason: string }[];
624
+ };
625
+
626
+ function parseConfirmMap(values: string[]): Record<string, string> {
627
+ const map: Record<string, string> = {};
628
+ for (const raw of values) {
629
+ const eq = raw.indexOf("=");
630
+ if (eq === -1) continue;
631
+ const slug = raw.slice(0, eq);
632
+ const evidence = raw.slice(eq + 1);
633
+ if (slug) map[slug] = evidence;
634
+ }
635
+ return map;
636
+ }
637
+
638
+ function performApply(result: AuditResult, options: {
639
+ artifactRoot: string;
640
+ archiveRoot: string;
641
+ freshnessWindowHours: number;
642
+ applyAmbiguous: boolean;
643
+ confirmMap: Record<string, string>;
644
+ }): ApplyReport {
645
+ const nowMs = Date.now();
646
+ const opts: ApplyOptions = {
647
+ artifactRoot: options.artifactRoot,
648
+ freshnessWindowHours: options.freshnessWindowHours,
649
+ applyAmbiguous: options.applyAmbiguous,
650
+ nowMs,
651
+ };
652
+
653
+ const ambiguousReport: { slug: string; reason: string }[] = [];
654
+ const toMove: { item: AuditItem; workflowDir: string }[] = [];
655
+
656
+ for (const items of Object.values(result.buckets)) {
657
+ for (const item of items) {
658
+ const workflowDir = item.path;
659
+ const eligibility = eligibleForApply(item, workflowDir, opts);
660
+ if (eligibility.ambiguous && !eligibility.eligible) {
661
+ ambiguousReport.push({ slug: item.slug, reason: eligibility.skipReason ?? "ambiguous lifecycle shape" });
662
+ }
663
+ if (eligibility.eligible) toMove.push({ item, workflowDir });
664
+ }
665
+ }
666
+
667
+ const archiveRunDir = path.join(options.archiveRoot, `${runDate()}-${runId()}`);
668
+ const moves: AppliedMove[] = [];
669
+ const manifestMoves: ManifestMove[] = [];
670
+
671
+ for (const { item, workflowDir } of toMove) {
672
+ // Re-check liveness + freshness immediately before the actual move, narrowing the
673
+ // TOCTOU window between classification and the move itself (plan stop-short-risk #2).
674
+ const slug = item.slug;
675
+ if (hasFreshLivenessClaim(options.artifactRoot, slug, Date.now())) continue;
676
+ if (withinFreshnessWindow(workflowDir, options.freshnessWindowHours, Date.now())) continue;
677
+
678
+ const { ms } = lastUpdatedMs(workflowDir);
679
+ const destination = path.join(archiveRunDir, slug);
680
+ archiveMove(workflowDir, destination);
681
+ moves.push({ slug, from: workflowDir, to: destination, classification: item.classification, reason: item.reasons.join("; ") });
682
+ manifestMoves.push({
683
+ slug,
684
+ classification: item.classification,
685
+ last_updated: new Date(ms).toISOString(),
686
+ reasons: item.reasons,
687
+ confirmation_evidence: options.confirmMap[slug],
688
+ });
689
+ }
690
+
691
+ let manifestPath: string | null = null;
692
+ if (manifestMoves.length > 0) {
693
+ writeManifest(archiveRunDir, manifestMoves);
694
+ manifestPath = path.join(archiveRunDir, "MANIFEST.md");
695
+ }
696
+
697
+ return {
698
+ applied: true,
699
+ dry_run: false,
700
+ archive_run_dir: moves.length > 0 ? archiveRunDir : null,
701
+ manifest_path: manifestPath,
702
+ moves,
703
+ ambiguous: ambiguousReport,
704
+ };
705
+ }
706
+
707
+ function printAppliedText(report: ApplyReport): void {
708
+ console.log("");
709
+ console.log(`APPLIED: ${report.moves.length} director${report.moves.length === 1 ? "y" : "ies"} archived`);
710
+ for (const move of report.moves) {
711
+ console.log(` - ${move.slug} -> ${move.to}`);
712
+ console.log(` reason: ${move.reason}`);
713
+ }
714
+ if (report.manifest_path) console.log(`Manifest: ${report.manifest_path}`);
715
+ if (report.ambiguous.length > 0) {
716
+ console.log("");
717
+ console.log(`Ambiguous (needs --apply-ambiguous): ${report.ambiguous.length}`);
718
+ for (const entry of report.ambiguous) console.log(` - ${entry.slug}: ${entry.reason}`);
719
+ }
720
+ }
721
+
298
722
  export function main(argv = process.argv.slice(2)): number {
299
723
  const args = parseArgs(argv);
300
724
  if (flagBool(args.flags, "help") || flagBool(args.flags, "h")) {
@@ -302,15 +726,64 @@ export function main(argv = process.argv.slice(2)): number {
302
726
  return 0;
303
727
  }
304
728
  let result: AuditResult;
729
+ let root: string;
305
730
  try {
306
- const root = flagString(args.flags, "artifact-root") ? path.resolve(flagString(args.flags, "artifact-root")!) : defaultArtifactRootForRead();
731
+ root = flagString(args.flags, "artifact-root") ? path.resolve(flagString(args.flags, "artifact-root")!) : defaultArtifactRootForRead();
307
732
  result = audit(root);
308
733
  } catch (error) {
309
734
  console.error(`workflow-artifact-cleanup-audit: ${error instanceof Error ? error.message : String(error)}`);
310
735
  return 1;
311
736
  }
312
- if (flagBool(args.flags, "json")) console.log(JSON.stringify(result, null, 2));
313
- else printText(result);
737
+
738
+ const apply = flagBool(args.flags, "apply");
739
+ const applyAmbiguous = flagBool(args.flags, "apply-ambiguous");
740
+ const json = flagBool(args.flags, "json");
741
+
742
+ // Two-gate model (plan-pinned, Task 1 Changes: "--apply-ambiguous (bool, requires
743
+ // --apply to have any effect)"): --apply-ambiguous ALONE performs zero mutation. The
744
+ // mutation path below is entered ONLY when --apply itself is set; --apply-ambiguous
745
+ // without --apply degrades to the same read-only dry-run output as no flags at all —
746
+ // it is a second, additive gate on top of --apply, never an independent trigger. This
747
+ // is the strict form of the plan's "requires --apply" language: a bare --apply-ambiguous
748
+ // invocation is treated as a no-op rather than silently doing nothing while looking like
749
+ // it might have done something, matching AC1's dry-run-default guarantee.
750
+ if (!apply) {
751
+ if (json) console.log(JSON.stringify(result, null, 2));
752
+ else printText(result);
753
+ return 0;
754
+ }
755
+
756
+ // AC8: freshness window / archive root are configurable ONLY via CLI flags — no
757
+ // environment variable is ever read here to loosen or redirect either.
758
+ const freshnessWindowHoursRaw = flagString(args.flags, "freshness-window-hours");
759
+ const freshnessWindowHours = freshnessWindowHoursRaw ? Number(freshnessWindowHoursRaw) : 48;
760
+ if (!Number.isFinite(freshnessWindowHours) || freshnessWindowHours < 0) {
761
+ console.error("workflow-artifact-cleanup-audit: --freshness-window-hours must be a non-negative number");
762
+ return 1;
763
+ }
764
+ const archiveRoot = flagString(args.flags, "archive-root") ? path.resolve(flagString(args.flags, "archive-root")!) : defaultArchiveRoot(root);
765
+ const confirmMap = parseConfirmMap(flagList(args.flags, "confirm"));
766
+
767
+ let report: ApplyReport;
768
+ try {
769
+ report = performApply(result, {
770
+ artifactRoot: root,
771
+ archiveRoot,
772
+ freshnessWindowHours,
773
+ applyAmbiguous,
774
+ confirmMap,
775
+ });
776
+ } catch (error) {
777
+ console.error(`workflow-artifact-cleanup-audit: ${error instanceof Error ? error.message : String(error)}`);
778
+ return 1;
779
+ }
780
+
781
+ if (json) {
782
+ console.log(JSON.stringify({ ...result, ...report }, null, 2));
783
+ } else {
784
+ printText(result);
785
+ printAppliedText(report);
786
+ }
314
787
  return 0;
315
788
  }
316
789