@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.
- package/.github/workflows/ci.yml +16 -0
- package/CHANGELOG.md +30 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +107 -13
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +238 -4
- package/build/src/cli/workflow-sidecar.js +875 -14
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/context/scripts/hooks/workflow-steering.js +73 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/index.md +2 -2
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/fixture-ownership.md +3 -2
- package/docs/index.md +4 -0
- package/docs/integrations/flow-agents-console.md +108 -0
- package/docs/integrations/index.md +4 -0
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +8 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/integration/test_checkpoint_signing.sh +14 -5
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +73 -8
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +125 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +22 -2
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/veritas-governance/docs/README.md +81 -3
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +5 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +222 -279
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +122 -13
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +965 -14
- 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>
|
|
28
|
-
console.log(" --json
|
|
29
|
-
console.log(" --
|
|
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.
|
|
33
|
-
console.log("
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|