@ludecker/aaac 1.1.6 → 1.2.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 (79) hide show
  1. package/package.json +9 -9
  2. package/src/cli.mjs +0 -0
  3. package/src/run-engine/debug-run.mjs +0 -0
  4. package/src/run-engine/log-dump.mjs +0 -0
  5. package/src/run-engine/log-trace.mjs +0 -0
  6. package/templates/cursor/aaac/enforcement.json +84 -3
  7. package/templates/cursor/aaac/graph.project.yaml +28 -0
  8. package/templates/cursor/aaac/lifecycle/lifecycle.json +14 -0
  9. package/templates/cursor/aaac/lifecycle/phases.json +7 -1
  10. package/templates/cursor/aaac/ontology.json +1 -0
  11. package/templates/cursor/aaac/project.config.json +36 -0
  12. package/templates/cursor/aaac/scripts/remediation/auto-check-swarm-synthesis.mjs +75 -0
  13. package/templates/cursor/aaac/scripts/remediation/auto-dispatch-queue-from-health.mjs +78 -0
  14. package/templates/cursor/aaac/scripts/remediation/bootstrap-autonomous.mjs +113 -0
  15. package/templates/cursor/aaac/scripts/remediation/capture-verify-baseline.mjs +66 -0
  16. package/templates/cursor/aaac/scripts/remediation/capture-wave-snapshot.mjs +79 -0
  17. package/templates/cursor/aaac/scripts/remediation/check-swarm-raw.template.json +26 -0
  18. package/templates/cursor/aaac/scripts/remediation/classify-fallow-issues.mjs +77 -0
  19. package/templates/cursor/aaac/scripts/remediation/classify-verify-failure.mjs +176 -0
  20. package/templates/cursor/aaac/scripts/remediation/compute-satisfaction.mjs +344 -0
  21. package/templates/cursor/aaac/scripts/remediation/debt-sweep-gate.mjs +202 -0
  22. package/templates/cursor/aaac/scripts/remediation/dispatch-rules.json +44 -0
  23. package/templates/cursor/aaac/scripts/remediation/fallow-fp-rules.json +87 -0
  24. package/templates/cursor/aaac/scripts/remediation/fallow-scan.mjs +219 -0
  25. package/templates/cursor/aaac/scripts/remediation/handle-yield.mjs +240 -0
  26. package/templates/cursor/aaac/scripts/remediation/init-campaign.mjs +211 -0
  27. package/templates/cursor/aaac/scripts/remediation/lib/autonomous-mode.mjs +63 -0
  28. package/templates/cursor/aaac/scripts/remediation/lib/campaign-focus.mjs +87 -0
  29. package/templates/cursor/aaac/scripts/remediation/lib/fallow-classifier.mjs +190 -0
  30. package/templates/cursor/aaac/scripts/remediation/lib/fallow-health-targets.mjs +56 -0
  31. package/templates/cursor/aaac/scripts/remediation/lib/fallow-metrics.mjs +119 -0
  32. package/templates/cursor/aaac/scripts/remediation/lib/invoke-cursor-agent.mjs +51 -0
  33. package/templates/cursor/aaac/scripts/remediation/lib/reconcile-run-manifest.mjs +41 -0
  34. package/templates/cursor/aaac/scripts/remediation/lib/regression-analysis.mjs +55 -0
  35. package/templates/cursor/aaac/scripts/remediation/lib/remediation-config.mjs +69 -0
  36. package/templates/cursor/aaac/scripts/remediation/lib/remediation-progress.mjs +58 -0
  37. package/templates/cursor/aaac/scripts/remediation/lib/remediation-watch-loop.mjs +168 -0
  38. package/templates/cursor/aaac/scripts/remediation/lib/runner-exec.mjs +156 -0
  39. package/templates/cursor/aaac/scripts/remediation/lib/runner-state.mjs +145 -0
  40. package/templates/cursor/aaac/scripts/remediation/lib/verify-metrics.mjs +205 -0
  41. package/templates/cursor/aaac/scripts/remediation/merge-check-swarm.mjs +257 -0
  42. package/templates/cursor/aaac/scripts/remediation/plan-waves-from-queue.mjs +85 -0
  43. package/templates/cursor/aaac/scripts/remediation/prepare-check-context.mjs +148 -0
  44. package/templates/cursor/aaac/scripts/remediation/record-fallow-fp.mjs +107 -0
  45. package/templates/cursor/aaac/scripts/remediation/record-iteration-step.mjs +56 -0
  46. package/templates/cursor/aaac/scripts/remediation/remediation-cli.mjs +157 -0
  47. package/templates/cursor/aaac/scripts/remediation/remediation-cursor-watch.sh +10 -0
  48. package/templates/cursor/aaac/scripts/remediation/remediation-runner-daemon.sh +13 -0
  49. package/templates/cursor/aaac/scripts/remediation/remediation-runner.mjs +748 -0
  50. package/templates/cursor/aaac/scripts/remediation/remediation-yield-watcher.mjs +40 -0
  51. package/templates/cursor/aaac/scripts/remediation/remediator-gate.mjs +405 -0
  52. package/templates/cursor/aaac/scripts/remediation/repair-fallow-start-baseline.mjs +118 -0
  53. package/templates/cursor/aaac/scripts/remediation/runner-health-check.mjs +164 -0
  54. package/templates/cursor/aaac/scripts/remediation/satisfaction-loop-gate.mjs +286 -0
  55. package/templates/cursor/aaac/scripts/remediation/validate-campaign-complete.mjs +191 -0
  56. package/templates/cursor/aaac/scripts/remediation/verify-remediation-iteration.mjs +112 -0
  57. package/templates/cursor/aaac/scripts/run-engine/debug-run.mjs +0 -0
  58. package/templates/cursor/aaac/scripts/run-engine/log-dump.mjs +0 -0
  59. package/templates/cursor/aaac/scripts/run-engine/log-trace.mjs +0 -0
  60. package/templates/cursor/agents/remediation-check-app-inventory.md +32 -0
  61. package/templates/cursor/agents/remediation-check-app-ssot.md +24 -0
  62. package/templates/cursor/agents/remediation-check-app-trace.md +29 -0
  63. package/templates/cursor/agents/remediation-check-architecture-boundaries.md +21 -0
  64. package/templates/cursor/agents/remediation-check-architecture-decomposition.md +25 -0
  65. package/templates/cursor/agents/remediation-check-architecture-deps.md +23 -0
  66. package/templates/cursor/agents/remediation-check-risk.md +37 -0
  67. package/templates/cursor/agents/remediation-e2e-gate.md +30 -0
  68. package/templates/cursor/agents/remediation-remediator.md +69 -0
  69. package/templates/cursor/commands/remediate-app.md +212 -0
  70. package/templates/cursor/hooks/aaac-before-submit.sh +0 -0
  71. package/templates/cursor/hooks/aaac-pre-tool.sh +0 -0
  72. package/templates/cursor/hooks/aaac-stop.sh +0 -0
  73. package/templates/cursor/hooks/aaac-subagent-start.sh +0 -0
  74. package/templates/cursor/skills/shared/remediation/SKILL.md +51 -0
  75. package/templates/cursor/skills/shared/remediation/babysit/SKILL.md +223 -0
  76. package/templates/cursor/skills/shared/remediation/check-swarm/SKILL.md +114 -0
  77. package/templates/cursor/skills/shared/remediation/orchestrator/SKILL.md +275 -0
  78. package/templates/cursor/skills/shared/remediation/orchestrator/contract.yaml +116 -0
  79. package/templates/docs/agentic_architecture.md +1 -0
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Safety checks for remediation runner + babysit loop.
4
+ *
5
+ * Exit codes:
6
+ * 0 — healthy
7
+ * 1 — stall or regression detected (babysit should investigate)
8
+ * 2 — missing state
9
+ *
10
+ * Usage:
11
+ * node runner-health-check.mjs --campaign-id <id> [--max-stall-ticks 20] [--max-yield-minutes 120]
12
+ */
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import {
16
+ campaignDir,
17
+ iterDir,
18
+ loadCampaign,
19
+ loadRunnerState,
20
+ loadYield,
21
+ runnerStatePath,
22
+ } from "./lib/runner-state.mjs";
23
+ import { readStartBaseline } from "./lib/fallow-metrics.mjs";
24
+
25
+ function parseArgs(argv) {
26
+ const out = { campaignId: null, maxStallTicks: 20, maxYieldMinutes: 120 };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === "--campaign-id") out.campaignId = argv[++i];
30
+ else if (a === "--max-stall-ticks") out.maxStallTicks = Number(argv[++i]);
31
+ else if (a === "--max-yield-minutes") out.maxYieldMinutes = Number(argv[++i]);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function minutesSince(iso) {
37
+ if (!iso) return Infinity;
38
+ return (Date.now() - new Date(iso).getTime()) / 60_000;
39
+ }
40
+
41
+ const args = parseArgs(process.argv.slice(2));
42
+ if (!args.campaignId) {
43
+ console.error("runner-health-check: --campaign-id required");
44
+ process.exit(2);
45
+ }
46
+
47
+ const campaign = loadCampaign(args.campaignId);
48
+ const state = loadRunnerState(args.campaignId);
49
+ if (!campaign || !state) {
50
+ console.error("Missing campaign or runner-state.json");
51
+ process.exit(2);
52
+ }
53
+
54
+ const issues = [];
55
+ const warnings = [];
56
+
57
+ if (state.stall_count >= args.maxStallTicks) {
58
+ issues.push({
59
+ code: "stall_ticks",
60
+ message: `No score/clone progress for ${state.stall_count} ticks`,
61
+ });
62
+ }
63
+
64
+ const yieldPayload = loadYield(args.campaignId);
65
+ if (state.status === "yielded" && yieldPayload) {
66
+ const yieldAge = minutesSince(yieldPayload.at ?? state.updated_at);
67
+ if (yieldAge > args.maxYieldMinutes) {
68
+ issues.push({
69
+ code: "yield_timeout",
70
+ message: `Agent yield pending ${Math.round(yieldAge)}m (type=${yieldPayload.type})`,
71
+ yield: yieldPayload,
72
+ });
73
+ }
74
+ }
75
+
76
+ const cDir = campaignDir(args.campaignId);
77
+ const dupesStart = readStartBaseline(cDir, "fallow-start-dupes-baseline.json", "clone_groups");
78
+ const healthStart = readStartBaseline(cDir, "fallow-start-health-baseline.json", "health_score");
79
+ const currentDupes = campaign.current?.fallow_dupes_clone_groups;
80
+ const currentHealth = campaign.current?.fallow_health_score;
81
+
82
+ if (
83
+ dupesStart.value != null &&
84
+ currentDupes != null &&
85
+ currentDupes > dupesStart.value * 1.05
86
+ ) {
87
+ issues.push({
88
+ code: "dupes_regression",
89
+ message: `Clone groups ${currentDupes} > baseline ${dupesStart.value}`,
90
+ });
91
+ }
92
+
93
+ if (
94
+ healthStart.value != null &&
95
+ currentHealth != null &&
96
+ currentHealth < healthStart.value - 2
97
+ ) {
98
+ issues.push({
99
+ code: "health_regression",
100
+ message: `Health ${currentHealth} dropped >2 vs baseline ${healthStart.value}`,
101
+ });
102
+ }
103
+
104
+ const threshold = campaign.config?.satisfaction_threshold ?? 85;
105
+ const score = campaign.current?.satisfaction_score;
106
+ const maxIter = campaign.config?.max_iterations ?? 5;
107
+ if (
108
+ score != null &&
109
+ score < threshold &&
110
+ state.iteration >= maxIter - 1 &&
111
+ state.phase !== "report"
112
+ ) {
113
+ warnings.push({
114
+ code: "near_max_iterations",
115
+ message: `Iteration ${state.iteration}/${maxIter - 1} with score ${score}/${threshold}`,
116
+ });
117
+ }
118
+
119
+ if (state.status === "running" && !yieldPayload) {
120
+ const sinceProgress = minutesSince(state.last_progress_at);
121
+ if (sinceProgress > args.maxYieldMinutes && score != null && score < threshold) {
122
+ warnings.push({
123
+ code: "long_run_no_progress",
124
+ message: `No metric progress in ${Math.round(sinceProgress)}m`,
125
+ });
126
+ }
127
+ }
128
+
129
+ const historyPath = path.join(cDir, "satisfaction-history.yaml");
130
+ if (fs.existsSync(historyPath)) {
131
+ try {
132
+ const raw = fs.readFileSync(historyPath, "utf8");
133
+ const parsed = JSON.parse(raw);
134
+ if (parsed?.entries?.length >= 3) {
135
+ const last3 = parsed.entries.slice(-3);
136
+ const scores = last3.map((e) => e.score);
137
+ if (scores[0] === scores[1] && scores[1] === scores[2]) {
138
+ warnings.push({
139
+ code: "flat_satisfaction",
140
+ message: `Score flat at ${scores[2]} for 3 iterations`,
141
+ });
142
+ }
143
+ }
144
+ } catch {
145
+ /* ignore parse errors */
146
+ }
147
+ }
148
+
149
+ const healthy = issues.length === 0;
150
+ const payload = {
151
+ ok: healthy,
152
+ campaign_id: args.campaignId,
153
+ runner_status: state.status,
154
+ phase: state.phase,
155
+ iteration: state.iteration,
156
+ score,
157
+ threshold,
158
+ issues,
159
+ warnings,
160
+ runner_state_path: runnerStatePath(args.campaignId),
161
+ };
162
+
163
+ console.log(JSON.stringify(payload, null, 2));
164
+ process.exit(healthy ? 0 : 1);
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Enforced satisfaction loop decision — blocks premature report / run completion.
4
+ *
5
+ * Exit codes:
6
+ * 0 — allow report (threshold met OR max_iterations exhausted)
7
+ * 1 — prerequisites missing (debt sweep incomplete, verify fail)
8
+ * 3 — continue loop (increment iteration → scan); orchestrator MUST NOT report
9
+ *
10
+ * Usage:
11
+ * node satisfaction-loop-gate.mjs --campaign-id <id> --iteration <n> \
12
+ * [--run-id <run_id>] [--advance] [--recompute]
13
+ */
14
+ import fs from "fs";
15
+ import path from "path";
16
+ import { spawnSync } from "child_process";
17
+ import { fileURLToPath } from "url";
18
+ import { readStartBaseline } from "./lib/fallow-metrics.mjs";
19
+ import { resolveActionableBaseline } from "./lib/fallow-classifier.mjs";
20
+ import { REPO_ROOT, isoNow, readJson, writeJson, runDir } from "../run-engine/lib.mjs";
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const CAMPAIGNS_ROOT = path.join(REPO_ROOT, ".cursor/aaac/state/campaigns");
24
+ const COMPUTE_SCRIPT = path.join(__dirname, "compute-satisfaction.mjs");
25
+
26
+ function parseArgs(argv) {
27
+ const out = {
28
+ campaignId: null,
29
+ iteration: 0,
30
+ runId: null,
31
+ advance: false,
32
+ recompute: false,
33
+ };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === "--campaign-id") out.campaignId = argv[++i];
37
+ else if (a === "--iteration") out.iteration = Number(argv[++i]);
38
+ else if (a === "--run-id") out.runId = argv[++i];
39
+ else if (a === "--advance") out.advance = true;
40
+ else if (a === "--recompute") out.recompute = true;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function campaignDir(id) {
46
+ return path.join(CAMPAIGNS_ROOT, id);
47
+ }
48
+
49
+ function appendJournal(campaignId, text) {
50
+ fs.appendFileSync(path.join(campaignDir(campaignId), "journal.md"), text);
51
+ }
52
+
53
+ function runCompute(campaignId, iteration) {
54
+ const result = spawnSync(
55
+ process.execPath,
56
+ [COMPUTE_SCRIPT, "--campaign-id", campaignId, "--iteration", String(iteration)],
57
+ { encoding: "utf8" },
58
+ );
59
+ try {
60
+ return JSON.parse(result.stdout.trim().split("\n").pop());
61
+ } catch {
62
+ return { ok: false, error: "compute-satisfaction parse failed", stderr: result.stderr };
63
+ }
64
+ }
65
+
66
+ function verifyStrictPass(iterDir) {
67
+ const verify =
68
+ readJson(path.join(iterDir, "verify-debt.json"), null) ??
69
+ readJson(path.join(iterDir, "verify-iteration.json"), null);
70
+ if (!verify) return { pass: false, reason: "missing_verify_debt" };
71
+ const totalErrors = verify.metrics?.total_errors ?? 0;
72
+ const pass =
73
+ verify.status === "pass" &&
74
+ totalErrors === 0 &&
75
+ verify.typecheck?.status === "pass" &&
76
+ verify.vitest?.status === "pass" &&
77
+ (verify.go_test?.status === "pass" || verify.go_test?.status === "skipped") &&
78
+ verify.build?.status === "pass" &&
79
+ verify.playwright?.status === "pass";
80
+ return { pass, verify, totalErrors };
81
+ }
82
+
83
+ function fallowRegression(campaign, satisfaction) {
84
+ const cDir = campaignDir(campaign.campaign_id);
85
+ const regressions = [];
86
+
87
+ const actionableBaseline = resolveActionableBaseline(cDir);
88
+ const startActionable =
89
+ actionableBaseline?.actionable_total ??
90
+ readJson(path.join(cDir, "fallow-start-baseline.json"), null)?.fallow_total_issues ??
91
+ campaign.baseline?.fallow_total_issues ??
92
+ null;
93
+ const currentActionable =
94
+ satisfaction.fallow_actionable_total ??
95
+ satisfaction.fallow_total_issues ??
96
+ satisfaction.fallow_raw_total ??
97
+ 0;
98
+
99
+ if (startActionable != null && currentActionable > startActionable * 1.05) {
100
+ regressions.push("dead_code");
101
+ }
102
+
103
+ const dupesStart = readStartBaseline(cDir, "fallow-start-dupes-baseline.json", "clone_groups");
104
+ const currentDupes = satisfaction.fallow_dupes_clone_groups;
105
+ if (
106
+ dupesStart.value != null &&
107
+ currentDupes != null &&
108
+ currentDupes > dupesStart.value * 1.05
109
+ ) {
110
+ regressions.push("dupes");
111
+ }
112
+
113
+ const healthStart = readStartBaseline(cDir, "fallow-start-health-baseline.json", "health_score");
114
+ const currentHealth = satisfaction.fallow_health_score;
115
+ if (
116
+ healthStart.value != null &&
117
+ currentHealth != null &&
118
+ currentHealth < healthStart.value - 2
119
+ ) {
120
+ regressions.push("health");
121
+ }
122
+
123
+ return regressions;
124
+ }
125
+
126
+ const args = parseArgs(process.argv.slice(2));
127
+ if (!args.campaignId) {
128
+ console.error("satisfaction-loop-gate: --campaign-id required");
129
+ process.exit(2);
130
+ }
131
+
132
+ const cDir = campaignDir(args.campaignId);
133
+ const campaignPath = path.join(cDir, "campaign.json");
134
+ const campaign = readJson(campaignPath, null);
135
+ if (!campaign) {
136
+ console.error("satisfaction-loop-gate: campaign not found");
137
+ process.exit(2);
138
+ }
139
+
140
+ const iteration = args.iteration ?? campaign.iteration ?? 0;
141
+ const iterDir = path.join(cDir, "iterations", String(iteration));
142
+ const satisfactionPath = path.join(iterDir, "satisfaction.json");
143
+
144
+ if (args.recompute || !fs.existsSync(satisfactionPath)) {
145
+ runCompute(args.campaignId, iteration);
146
+ }
147
+
148
+ const satisfaction = readJson(satisfactionPath, null);
149
+ if (!satisfaction) {
150
+ console.error("satisfaction-loop-gate: satisfaction.json missing");
151
+ process.exit(2);
152
+ }
153
+
154
+ const threshold = campaign.config?.satisfaction_threshold ?? 85;
155
+ const maxIterations = campaign.config?.max_iterations ?? 5;
156
+ const debtSweep = campaign.debt_sweep ?? {};
157
+ const verifyCheck = verifyStrictPass(iterDir);
158
+
159
+ const violations = [];
160
+ if (debtSweep.status !== "complete" || debtSweep.iteration !== iteration) {
161
+ violations.push("debt_sweep_incomplete");
162
+ }
163
+ if (!verifyCheck.pass) {
164
+ violations.push(`verify_not_strict_pass:${verifyCheck.reason ?? verifyCheck.totalErrors}`);
165
+ }
166
+
167
+ if (violations.length) {
168
+ const output = {
169
+ action: "block",
170
+ status: "fail",
171
+ violations,
172
+ iteration,
173
+ satisfaction,
174
+ campaign_must_continue: true,
175
+ message: "Debt sweep or strict verify must pass before satisfaction loop decision",
176
+ };
177
+ if (args.runId) {
178
+ writeJson(path.join(runDir(args.runId), "artifacts", "satisfaction_loop_gate.json"), output);
179
+ }
180
+ console.log(JSON.stringify(output));
181
+ process.exit(1);
182
+ }
183
+
184
+ const score = satisfaction.score ?? 0;
185
+ const e2ePass = satisfaction.e2e_pass === true;
186
+ const vitestPass = satisfaction.vitest_pass === true;
187
+ const typecheckPass = satisfaction.typecheck_pass === true;
188
+ const buildPass = satisfaction.build_pass === true;
189
+ const regressions = fallowRegression(campaign, satisfaction);
190
+ const regressed = regressions.length > 0;
191
+
192
+ const allVerifyPass = e2ePass && vitestPass && typecheckPass && buildPass;
193
+ const thresholdMet = score >= threshold && allVerifyPass && !regressed;
194
+ const maxIterationsReached = iteration + 1 >= maxIterations;
195
+
196
+ if (thresholdMet) {
197
+ campaign.status = "satisfied";
198
+ campaign.updated_at = isoNow();
199
+ writeJson(campaignPath, campaign);
200
+
201
+ const output = {
202
+ action: "complete",
203
+ status: "pass",
204
+ reason: "satisfaction_threshold_met",
205
+ iteration,
206
+ score,
207
+ threshold,
208
+ allow_report: true,
209
+ campaign_must_continue: false,
210
+ fallow_regressions: regressions,
211
+ };
212
+ appendJournal(
213
+ args.campaignId,
214
+ `- Satisfaction loop **COMPLETE** iter ${iteration} — score ${score} >= ${threshold}\n`,
215
+ );
216
+ if (args.runId) {
217
+ writeJson(path.join(runDir(args.runId), "artifacts", "satisfaction_loop_gate.json"), output);
218
+ }
219
+ console.log(JSON.stringify(output));
220
+ process.exit(0);
221
+ }
222
+
223
+ if (maxIterationsReached) {
224
+ campaign.status = "running";
225
+ campaign.updated_at = isoNow();
226
+ writeJson(campaignPath, campaign);
227
+
228
+ const output = {
229
+ action: "partial_complete",
230
+ status: "pass",
231
+ reason: "max_iterations_reached",
232
+ iteration,
233
+ score,
234
+ threshold,
235
+ allow_report: true,
236
+ campaign_must_continue: false,
237
+ message: `Max iterations (${maxIterations}) reached with score ${score}/${threshold}`,
238
+ };
239
+ appendJournal(
240
+ args.campaignId,
241
+ `- Satisfaction loop **PARTIAL** — max_iterations=${maxIterations}, score ${score}/${threshold}\n`,
242
+ );
243
+ if (args.runId) {
244
+ writeJson(path.join(runDir(args.runId), "artifacts", "satisfaction_loop_gate.json"), output);
245
+ }
246
+ console.log(JSON.stringify(output));
247
+ process.exit(0);
248
+ }
249
+
250
+ const nextIteration = iteration + 1;
251
+ if (args.advance) {
252
+ campaign.iteration = nextIteration;
253
+ campaign.debt_sweep = { status: "pending", iteration: null };
254
+ campaign.status = "running";
255
+ campaign.updated_at = isoNow();
256
+ writeJson(campaignPath, campaign);
257
+ appendJournal(
258
+ args.campaignId,
259
+ `- Satisfaction loop **CONTINUE** — iter ${iteration} score ${score}/${threshold} → iteration ${nextIteration}\n`,
260
+ );
261
+ }
262
+
263
+ const output = {
264
+ action: "continue_loop",
265
+ status: "fail",
266
+ reason: "below_satisfaction_threshold",
267
+ iteration,
268
+ next_iteration: nextIteration,
269
+ score,
270
+ threshold,
271
+ max_iterations: maxIterations,
272
+ allow_report: false,
273
+ campaign_must_continue: true,
274
+ orchestrator_must_not_stop: true,
275
+ orchestrator_must_not_report: true,
276
+ retry_command: `node .cursor/aaac/scripts/remediation/satisfaction-loop-gate.mjs --campaign-id ${args.campaignId} --iteration ${nextIteration}${args.runId ? ` --run-id ${args.runId}` : ""} --advance --recompute`,
277
+ next_phase: "scan",
278
+ message: `Score ${score} < ${threshold} with ${maxIterations - nextIteration} iteration(s) remaining — return to scan`,
279
+ };
280
+
281
+ if (args.runId) {
282
+ writeJson(path.join(runDir(args.runId), "artifacts", "satisfaction_loop_gate.json"), output);
283
+ }
284
+
285
+ console.log(JSON.stringify(output));
286
+ process.exit(3);
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hard invariants before remediate-app may advance to report / complete.
4
+ *
5
+ * Exit 0 — safe to report (or loop iteration with partial satisfaction)
6
+ * Exit 1 — blocked with reasons (orchestrator must NOT complete run)
7
+ *
8
+ * Usage:
9
+ * node validate-campaign-complete.mjs --campaign-id <id> [--iteration <n>] \
10
+ * [--require-debt-sweep] [--require-satisfaction-loop]
11
+ */
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { REPO_ROOT, readJson } from "../run-engine/lib.mjs";
15
+
16
+ const CAMPAIGNS_ROOT = path.join(REPO_ROOT, ".cursor/aaac/state/campaigns");
17
+
18
+ function parseArgs(argv) {
19
+ const out = { campaignId: null, iteration: null, requireDebtSweep: false, requireSatisfactionLoop: false };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === "--campaign-id") out.campaignId = argv[++i];
23
+ else if (a === "--iteration") out.iteration = Number(argv[++i]);
24
+ else if (a === "--require-debt-sweep") out.requireDebtSweep = true;
25
+ else if (a === "--require-satisfaction-loop") out.requireSatisfactionLoop = true;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function iterDir(campaignId, n) {
31
+ return path.join(CAMPAIGNS_ROOT, campaignId, "iterations", String(n));
32
+ }
33
+
34
+ function checkRemediatorLoops(iterationPath) {
35
+ const violations = [];
36
+ if (!fs.existsSync(iterationPath)) return violations;
37
+ for (const name of fs.readdirSync(iterationPath)) {
38
+ if (!name.startsWith("remediator-loop-") || !name.endsWith(".json")) continue;
39
+ const state = readJson(path.join(iterationPath, name), {});
40
+ if (state.status === "running") {
41
+ violations.push(`remediator_loop_running:${name}`);
42
+ }
43
+ }
44
+ return violations;
45
+ }
46
+
47
+ function checkPlannedWaves(campaignId, iteration, runArtifactsDir) {
48
+ const violations = [];
49
+ const planPath = runArtifactsDir
50
+ ? path.join(runArtifactsDir, "plan_waves.yaml")
51
+ : null;
52
+ const executePath = runArtifactsDir
53
+ ? path.join(runArtifactsDir, "execute_waves.yaml")
54
+ : null;
55
+
56
+ let plannedCount = null;
57
+ if (planPath && fs.existsSync(planPath)) {
58
+ const text = fs.readFileSync(planPath, "utf8");
59
+ const matches = text.match(/^\s*-\s+priority:/gm);
60
+ plannedCount = matches?.length ?? 0;
61
+ }
62
+
63
+ if (executePath && fs.existsSync(executePath)) {
64
+ const executed = readJson(executePath.replace(/\.yaml$/, ".json"), null);
65
+ if (executed?.waves) {
66
+ const incomplete = executed.waves.filter(
67
+ (w) => !["completed", "degraded", "deferred", "promoted"].includes(w.status),
68
+ );
69
+ if (incomplete.length) {
70
+ violations.push(`waves_not_executed:${incomplete.map((w) => w.wave_index).join(",")}`);
71
+ }
72
+ }
73
+ } else if (plannedCount != null && plannedCount > 0) {
74
+ violations.push("execute_waves_artifact_missing");
75
+ }
76
+
77
+ const loopFiles = fs.existsSync(iterDir(campaignId, iteration))
78
+ ? fs.readdirSync(iterDir(campaignId, iteration)).filter((n) => n.startsWith("remediator-loop-wave-"))
79
+ : [];
80
+ if (plannedCount != null && loopFiles.length < plannedCount) {
81
+ violations.push(`wave_gates_incomplete:${loopFiles.length}/${plannedCount}`);
82
+ }
83
+
84
+ return violations;
85
+ }
86
+
87
+ function checkDebtSweep(campaign, iteration) {
88
+ const violations = [];
89
+ const sweep = campaign.debt_sweep;
90
+ if (!sweep || sweep.status !== "complete") {
91
+ violations.push("debt_sweep_incomplete");
92
+ } else if (sweep.iteration != null && sweep.iteration < iteration) {
93
+ violations.push("debt_sweep_stale_for_iteration");
94
+ }
95
+
96
+ const sweepState = readJson(path.join(iterDir(campaign.campaign_id, iteration), "debt-sweep-state.json"), null);
97
+ if (sweepState?.status === "running") {
98
+ violations.push("debt_sweep_running");
99
+ }
100
+
101
+ const verifyDebt = readJson(path.join(iterDir(campaign.campaign_id, iteration), "verify-debt.json"), null);
102
+ if (verifyDebt && (verifyDebt.status !== "pass" || (verifyDebt.metrics?.total_errors ?? 0) > 0)) {
103
+ violations.push(`verify_debt_errors:${verifyDebt.metrics?.total_errors ?? "unknown"}`);
104
+ }
105
+
106
+ return violations;
107
+ }
108
+
109
+ function checkSatisfactionLoop(campaign, iteration, runArtifactsDir) {
110
+ const violations = [];
111
+ const gatePath = runArtifactsDir
112
+ ? path.join(runArtifactsDir, "satisfaction_loop_gate.json")
113
+ : null;
114
+ const gate = gatePath && fs.existsSync(gatePath) ? readJson(gatePath, null) : null;
115
+ if (!gate) {
116
+ violations.push("satisfaction_loop_gate_missing");
117
+ return violations;
118
+ }
119
+ if (gate.action === "continue_loop" || gate.allow_report === false) {
120
+ violations.push(`satisfaction_loop_blocks_report:${gate.action}`);
121
+ }
122
+ if (gate.orchestrator_must_not_report === true) {
123
+ violations.push("satisfaction_loop_must_continue");
124
+ }
125
+ const threshold = campaign.config?.satisfaction_threshold ?? 85;
126
+ const maxIter = campaign.config?.max_iterations ?? 5;
127
+ const satisfied = gate.action === "complete" && gate.reason === "satisfaction_threshold_met";
128
+ const partial = gate.action === "partial_complete" && gate.reason === "max_iterations_reached";
129
+ if (!satisfied && !partial && iteration + 1 < maxIter) {
130
+ const sat = readJson(path.join(iterDir(campaign.campaign_id, iteration), "satisfaction.json"), null);
131
+ if (sat && sat.score < threshold) {
132
+ violations.push(`satisfaction_below_threshold:${sat.score}<${threshold}`);
133
+ }
134
+ }
135
+ return violations;
136
+ }
137
+
138
+ const args = parseArgs(process.argv.slice(2));
139
+ if (!args.campaignId) {
140
+ console.error("validate-campaign-complete: --campaign-id required");
141
+ process.exit(2);
142
+ }
143
+
144
+ const campaign = readJson(path.join(CAMPAIGNS_ROOT, args.campaignId, "campaign.json"), null);
145
+ if (!campaign) {
146
+ console.error("validate-campaign-complete: campaign not found");
147
+ process.exit(2);
148
+ }
149
+
150
+ const iteration = args.iteration ?? campaign.iteration ?? 0;
151
+ const iterationPath = iterDir(args.campaignId, iteration);
152
+ const runArtifactsDir = campaign.run_id
153
+ ? path.join(REPO_ROOT, ".cursor/aaac/state/runs", campaign.run_id, "artifacts")
154
+ : null;
155
+
156
+ const violations = [
157
+ ...checkRemediatorLoops(iterationPath),
158
+ ...checkPlannedWaves(args.campaignId, iteration, runArtifactsDir),
159
+ ];
160
+
161
+ if (args.requireDebtSweep) {
162
+ violations.push(...checkDebtSweep(campaign, iteration));
163
+ }
164
+
165
+ if (args.requireSatisfactionLoop) {
166
+ violations.push(...checkSatisfactionLoop(campaign, iteration, runArtifactsDir));
167
+ }
168
+
169
+ if (campaign.status === "blocked" && args.requireDebtSweep) {
170
+ violations.push("campaign_status_blocked");
171
+ }
172
+
173
+ const ok = violations.length === 0;
174
+ const result = {
175
+ ok,
176
+ campaign_id: args.campaignId,
177
+ iteration,
178
+ violations,
179
+ invariants: {
180
+ no_running_remediator_loops: !violations.some((v) => v.startsWith("remediator_loop_running")),
181
+ all_waves_gated: !violations.some((v) => v.startsWith("wave_gates_incomplete") || v.startsWith("waves_not_executed")),
182
+ debt_sweep_complete: args.requireDebtSweep ? !violations.some((v) => v.includes("debt_sweep")) : null,
183
+ no_remaining_verify_errors: args.requireDebtSweep ? !violations.some((v) => v.startsWith("verify_debt")) : null,
184
+ satisfaction_loop_allows_report: args.requireSatisfactionLoop
185
+ ? !violations.some((v) => v.startsWith("satisfaction_loop"))
186
+ : null,
187
+ },
188
+ };
189
+
190
+ console.log(JSON.stringify(result));
191
+ process.exit(ok ? 0 : 1);