@felixdotgo/aidlc-kit 0.0.1 → 0.1.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 (92) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +5 -4
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  10. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
  16. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  43. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
  44. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  45. package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
  46. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  47. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  48. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  49. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  53. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  54. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  55. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  56. package/dist/services/aidlc-coordination/package.json +2 -1
  57. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  58. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  59. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  60. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  61. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
  63. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  64. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  66. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  67. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  68. package/dist/src/adapters.js +25 -5
  69. package/dist/src/cli.js +66 -6
  70. package/dist/src/gate.js +3 -3
  71. package/dist/src/installer.d.ts +5 -1
  72. package/dist/src/installer.js +114 -30
  73. package/dist/src/managed-content.js +1 -1
  74. package/dist/src/model.d.ts +15 -0
  75. package/dist/src/model.js +1 -1
  76. package/dist/src/profiles.d.ts +3 -1
  77. package/dist/src/profiles.js +26 -3
  78. package/dist/src/state.d.ts +7 -0
  79. package/dist/src/state.js +54 -3
  80. package/dist/src/upgrade.d.ts +2 -2
  81. package/dist/src/upgrade.js +39 -4
  82. package/dist/src/workflow.d.ts +1 -0
  83. package/dist/src/workflow.js +2 -1
  84. package/docs/README.md +2 -2
  85. package/docs/architecture.md +6 -1
  86. package/docs/command-reference.md +63 -5
  87. package/docs/configuration.md +20 -1
  88. package/docs/coordination-service.md +54 -1
  89. package/docs/development.md +1 -1
  90. package/docs/operating-workflow.md +50 -14
  91. package/docs/testing-and-release.md +47 -4
  92. package/package.json +2 -2
package/dist/src/cli.js CHANGED
@@ -1,20 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdin, stdout } from "node:process";
3
+ import { spawnSync } from "node:child_process";
4
+ import { resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { join } from "node:path";
5
7
  import { installableAdapters } from "./adapters.js";
6
8
  import { applyPlan, detectedAgents, doctor, formatPlan, planInit, planUninstall, selectAgents, status } from "./installer.js";
7
- import { loadProjectConfig, resolveProfiles } from "./profiles.js";
9
+ import { LOOP_PROVIDERS } from "./model.js";
10
+ import { loadProjectConfig, resolveProfiles, validateLoopConfig } from "./profiles.js";
8
11
  import { applyUpgrade, planUpgrade } from "./upgrade.js";
9
12
  import { packageVersion } from "./workflow.js";
10
13
  import { applyMcpSetup, planMcpSetup } from "./mcp-setup.js";
11
14
  const usage = `Usage:
12
- npx @felixdotgo/aidlc-kit init [path] [--agent <name[,name]> | --all] [--yes] [--dry-run] [--force]
13
- npx @felixdotgo/aidlc-kit upgrade [path] [--dry-run]
15
+ npx @felixdotgo/aidlc-kit init [path] [--agent <name[,name]> | --all] [--loop-mode off|local|fleet] [--loop-lead ${LOOP_PROVIDERS.join("|")}] [--loop-workers <id=provider,...>] [--yes] [--dry-run] [--force]
16
+ npx @felixdotgo/aidlc-kit upgrade [path] [--loop-mode off|local|fleet] [--loop-lead ${LOOP_PROVIDERS.join("|")}] [--loop-workers <id=provider,...>] [--dry-run]
14
17
  npx @felixdotgo/aidlc-kit uninstall [path] [--yes] [--dry-run]
15
18
  npx @felixdotgo/aidlc-kit status [path]
16
19
  npx @felixdotgo/aidlc-kit doctor [path] [--strict]
17
20
  npx @felixdotgo/aidlc-kit profile validate [path]
21
+ npx @felixdotgo/aidlc-kit loop start|status|resume [--task-id ID] [--root PATH] [--mode local|fleet]
18
22
  npx @felixdotgo/aidlc-kit mcp setup [path] [--dry-run] [--yes] [--storage sqlite|postgres] [--deployment docker|remote] [--bind localhost|network] [--workspace <id>] [--poll-ms <n>] [--enable]`;
19
23
  const interactive = () => Boolean(stdin.isTTY && stdout.isTTY);
20
24
  const prompts = async () => import("@inquirer/prompts");
@@ -23,7 +27,7 @@ const value = (args, name) => {
23
27
  return index < 0 ? undefined : args[index + 1];
24
28
  };
25
29
  const flags = (args) => new Set(args.filter((item) => item.startsWith("--")));
26
- const valueFlags = new Set(["--agent", "--storage", "--deployment", "--bind", "--workspace", "--poll-ms", "--token-env", "--providers"]);
30
+ const valueFlags = new Set(["--agent", "--storage", "--deployment", "--bind", "--workspace", "--poll-ms", "--token-env", "--providers", "--loop-mode", "--loop-lead", "--loop-workers"]);
27
31
  const positional = (args) => {
28
32
  const result = [];
29
33
  for (let index = 0; index < args.length; index += 1) {
@@ -37,6 +41,42 @@ const positional = (args) => {
37
41
  return result;
38
42
  };
39
43
  const rootArg = (args, fallback = ".") => positional(args).find((item) => !["init", "upgrade", "uninstall", "status", "doctor", "validate", "run", "list"].includes(item)) ?? fallback;
44
+ const chooseLoop = async (args, upgrade) => {
45
+ let mode = value(args, "--loop-mode");
46
+ const leadArg = value(args, "--loop-lead"), workersArg = value(args, "--loop-workers");
47
+ if (!mode && (leadArg || workersArg))
48
+ throw new Error("--loop-lead and --loop-workers require --loop-mode");
49
+ if (!mode && interactive()) {
50
+ const { select } = await prompts();
51
+ mode = await select({ message: "Loop execution mode", choices: [
52
+ ...(upgrade ? [{ name: "Keep existing loop configuration", value: "keep" }] : []),
53
+ { name: "Off", value: "off" }, { name: "Local", value: "local" }, { name: "Fleet", value: "fleet" }
54
+ ] });
55
+ }
56
+ if (!mode || mode === "keep")
57
+ return undefined;
58
+ if (mode === "off") {
59
+ if (leadArg || workersArg)
60
+ throw new Error("Disabled loop cannot specify agents");
61
+ return validateLoopConfig({ mode });
62
+ }
63
+ let lead = leadArg, rawWorkers = workersArg;
64
+ if (interactive() && (!lead || !rawWorkers)) {
65
+ const { select, input } = await prompts();
66
+ lead ??= await select({ message: "Lead agent", choices: LOOP_PROVIDERS.map((name) => ({ name, value: name })) });
67
+ rawWorkers ??= await input({ message: "Workers (id=provider, comma separated)", default: "codex-worker=codex,claude-worker=claude" });
68
+ }
69
+ if (!lead || !rawWorkers)
70
+ throw new Error("An enabled loop requires --loop-lead and --loop-workers");
71
+ const workers = Object.create(null);
72
+ for (const entry of rawWorkers.split(",")) {
73
+ const parts = entry.split("=");
74
+ if (parts.length !== 2 || !parts[0] || !parts[1] || Object.hasOwn(workers, parts[0]))
75
+ throw new Error("Invalid or duplicate loop worker mapping");
76
+ workers[parts[0]] = parts[1];
77
+ }
78
+ return validateLoopConfig({ mode, agents: { lead, workers } });
79
+ };
40
80
  const initOptions = (args) => ({
41
81
  root: rootArg(args),
42
82
  agents: (value(args, "--agent") ?? "").split(",").filter(Boolean),
@@ -109,6 +149,26 @@ const main = async () => {
109
149
  const [command = "help", ...args] = process.argv.slice(2);
110
150
  if (command === "help" || command === "--help")
111
151
  return void console.log(usage);
152
+ if (command === "loop") {
153
+ const action = args[0];
154
+ if (!["start", "status", "resume"].includes(action))
155
+ throw new Error("Expected loop start, status, or resume");
156
+ const parsed = new Map();
157
+ for (let index = 1; index < args.length; index += 2) {
158
+ const key = args[index], entry = args[index + 1];
159
+ if (!["--task-id", "--root", "--mode"].includes(key) || !entry || entry.startsWith("--") || parsed.has(key))
160
+ throw new Error("Invalid or duplicate loop option");
161
+ parsed.set(key, entry);
162
+ }
163
+ const root = resolve(parsed.get("--root") ?? ".");
164
+ const script = join(root, ".agents", "aidlc", "scripts", "loop-entry.mjs");
165
+ const child = spawnSync(process.execPath, [script, action, parsed.get("--task-id") ?? "", root, parsed.get("--mode") ?? ""], { stdio: "inherit" });
166
+ if (child.error)
167
+ throw child.error;
168
+ if (child.status !== 0)
169
+ process.exitCode = child.status ?? 1;
170
+ return;
171
+ }
112
172
  if (command === "status")
113
173
  return void console.log(status(rootArg(args)));
114
174
  if (command === "doctor") {
@@ -119,7 +179,7 @@ const main = async () => {
119
179
  return;
120
180
  }
121
181
  if (command === "init") {
122
- const options = await chooseAgents(initOptions(args));
182
+ const options = { ...await chooseAgents(initOptions(args)), loop: await chooseLoop(args, false) };
123
183
  const plan = planInit(options);
124
184
  preview("INIT", plan);
125
185
  if (plan.some((item) => item.action === "conflict"))
@@ -151,7 +211,7 @@ const main = async () => {
151
211
  if (flags(args).has("--yes") || flags(args).has("--force"))
152
212
  throw new Error("upgrade does not support --yes or --force; apply requires a human interactive TTY confirmation");
153
213
  const root = rootArg(args);
154
- const plan = planUpgrade(root);
214
+ const plan = planUpgrade(root, await chooseLoop(args, true));
155
215
  preview("UPGRADE", plan);
156
216
  if (plan.some((item) => item.action === "conflict"))
157
217
  throw new Error("Upgrade contains conflicts; no files were written.");
package/dist/src/gate.js CHANGED
@@ -98,9 +98,9 @@ export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${i
98
98
  const oneLine = (value) => value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").replace(/ACTION\s+REQUIRED/gi, "ACTION-REQUIRED").trim();
99
99
  const markdownText = (value) => oneLine(value).replace(/([\\`*_{}\[\]()<>#+.!|>~])/g, "\\$1");
100
100
  const gateMeta = {
101
- G0_confirm: { icon: "🟢", title: "GATE G0 · CONFIRM INTENT", action: "Reply `ok` to approve intent, or state changes." },
102
- G1_review: { icon: "🔵", title: "GATE G1 · REVIEW PLAN", action: "Reply `approve` to start build, or state decision changes." },
103
- G2_codereview: { icon: "🟣", title: "GATE G2 · REVIEW CODE", action: "Approve to wrap, or point out what to fix." }
101
+ G0_confirm: { icon: "🟢", title: "Review step · Confirm requirements", action: "Reply `ok` to confirm requirements, or state changes." },
102
+ G1_review: { icon: "🔵", title: "Review step · Approve plan", action: "Reply `approve` to approve the plan and start implementation, or state decision changes." },
103
+ G2_codereview: { icon: "🟣", title: "Review step · Review implementation", action: "Approve the implementation to complete the task, or point out what to fix." }
104
104
  };
105
105
  const artifactRows = (task) => Object.entries(task.artifacts).flatMap(([name, path]) => path ? [{ name: oneLine(name), path: oneLine(path) }] : []);
106
106
  const markdownLinkTarget = (value) => encodeURI(value).replace(/[()#?]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
@@ -1,8 +1,12 @@
1
- import type { AgentId, FileSpec, InitOptions, PlannedWrite, WorkflowManifest } from "./model.js";
1
+ import type { AgentId, FileSpec, InitOptions, LoopConfig, ManifestInventoryItem, PlannedWrite, WorkflowManifest } from "./model.js";
2
+ /** Managed prompt blocks share a path across owners, so inventory keys carry the owner. */
3
+ export declare const installedHashFor: (files: Record<string, ManifestInventoryItem> | undefined, spec: Pick<FileSpec, "path" | "owner" | "strategy">) => string | undefined;
2
4
  export declare const managedContentHash: (spec: Pick<FileSpec, "owner" | "strategy">, content: string) => string | undefined;
3
5
  export declare const readManifest: (root: string) => WorkflowManifest | undefined;
4
6
  export declare const detectedAgents: (root: string) => AgentId[];
5
7
  export declare const selectAgents: (options: Pick<InitOptions, "root" | "agents" | "all">) => AgentId[];
8
+ /** Include an explicitly selected loop config in the same preview/transaction as setup. */
9
+ export declare const planLoopSelection: (plan: PlannedWrite[], selection?: LoopConfig) => void;
6
10
  export declare const planInit: (options: InitOptions) => PlannedWrite[];
7
11
  export declare const applyPlan: (root: string, plan: PlannedWrite[]) => void;
8
12
  export declare const formatPlan: (plan: PlannedWrite[]) => string;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { adapters, findAdapter, installableAdapters, resolvedAdapters } from "./adapters.js";
4
- import { loadProjectConfig } from "./profiles.js";
4
+ import { loadProjectConfig, validateLoopConfig } from "./profiles.js";
5
5
  import { loadState } from "./state.js";
6
6
  import { planProjectLayoutMigration } from "./project-migration.js";
7
7
  import { managedBlock, mergeManagedBlock } from "./managed-content.js";
@@ -9,6 +9,16 @@ import { resolveProjectPathWithoutSymlinks } from "./project-path.js";
9
9
  import { contentHash, coreWorkflowFiles, initialProjectFiles, manifestSpec, packageVersion } from "./workflow.js";
10
10
  const shortHash = (content) => contentHash(content).slice(0, 12);
11
11
  const safeTarget = (root, path) => resolveProjectPathWithoutSymlinks(root, path, "Managed path escapes the project", "Managed path crosses a symlink");
12
+ /** Managed prompt blocks share a path across owners, so inventory keys carry the owner. */
13
+ export const installedHashFor = (files, spec) => {
14
+ if (!files)
15
+ return undefined;
16
+ const keyed = files[`${spec.path}#${spec.owner}`];
17
+ if (keyed)
18
+ return keyed.hash;
19
+ const legacy = files[spec.path];
20
+ return legacy && (spec.strategy !== "managed-block" || legacy.owner === spec.owner) ? legacy.hash : undefined;
21
+ };
12
22
  export const managedContentHash = (spec, content) => {
13
23
  const managed = managedBlock(spec, content);
14
24
  return managed === undefined ? undefined : contentHash(managed);
@@ -51,13 +61,33 @@ const planFile = (root, spec, force, installed) => {
51
61
  return { ...spec, action: "skip", reason: "already current" };
52
62
  if (spec.path === ".agents/aidlc/manifest.json" && installed)
53
63
  return { ...spec, action: "update", reason: "refresh manifest inventory" };
54
- const installedHash = installed?.files[spec.path]?.hash;
64
+ const installedHash = installedHashFor(installed?.files, spec);
55
65
  if (installedHash && contentHash(current) === installedHash)
56
66
  return { ...spec, action: "update", reason: "unchanged managed file" };
57
67
  if (force)
58
68
  return { ...spec, action: "update", reason: "--force permits replacement of an unmanaged file" };
59
69
  return { ...spec, action: "conflict", reason: "managed ownership cannot be proven; use upgrade or review --force" };
60
70
  };
71
+ /** Include an explicitly selected loop config in the same preview/transaction as setup. */
72
+ export const planLoopSelection = (plan, selection) => {
73
+ if (selection === undefined)
74
+ return;
75
+ const loop = validateLoopConfig(selection);
76
+ const config = plan.find((item) => item.path === ".agents/config.json");
77
+ if (!config || !["create", "preserve", "skip", "migrate"].includes(config.action))
78
+ throw new Error("Cannot change loop configuration while the project config has another planned change");
79
+ const parsed = JSON.parse(config.content);
80
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
81
+ throw new Error("Invalid project config");
82
+ const current = parsed;
83
+ if (JSON.stringify(current.loop) === JSON.stringify(loop))
84
+ return;
85
+ const previousAction = config.action;
86
+ current.loop = loop;
87
+ config.content = `${JSON.stringify(current, null, 2)}\n`;
88
+ config.action = previousAction === "create" || previousAction === "migrate" ? previousAction : "update";
89
+ config.reason = "explicit loop mode selection";
90
+ };
61
91
  export const planInit = (options) => {
62
92
  const root = resolve(options.root);
63
93
  const installed = readManifest(root);
@@ -70,8 +100,21 @@ export const planInit = (options) => {
70
100
  const agentFiles = selected.flatMap((id) => findAdapter(id)?.files() ?? []).map((file) => ({ ...file, ownershipClass: "managed" }));
71
101
  const managedFiles = [...coreWorkflowFiles(), ...agentFiles];
72
102
  const files = [...managedFiles, manifestSpec(managedFiles, selected), ...initialProjectFiles()];
73
- const plan = files.map((spec) => planFile(root, spec, options.force, installed));
103
+ const plan = [];
104
+ for (const spec of files) {
105
+ const previous = spec.strategy === "managed-block" ? plan.find((item) => item.path === spec.path && ["create", "update", "skip"].includes(item.action)) : undefined;
106
+ if (!previous) {
107
+ plan.push(planFile(root, spec, options.force, installed));
108
+ continue;
109
+ }
110
+ const target = safeTarget(root, spec.path);
111
+ const current = existsSync(target) ? readFileSync(target, "utf8") : "";
112
+ previous.content = mergeManagedBlock(spec, previous.content);
113
+ previous.action = existsSync(target) ? (previous.content === current ? "skip" : "update") : "create";
114
+ previous.reason = existsSync(target) ? "managed prompt block" : "new managed prompt";
115
+ }
74
116
  planProjectLayoutMigration(root, plan);
117
+ planLoopSelection(plan, options.loop);
75
118
  return plan;
76
119
  };
77
120
  export const applyPlan = (root, plan) => {
@@ -95,33 +138,73 @@ const hasOwnedManifest = (root) => {
95
138
  return false;
96
139
  }
97
140
  };
141
+ const removeManagedBlock = (current, owner) => {
142
+ const start = `<!-- aidlc-installer:${owner}:start -->`;
143
+ const end = `<!-- aidlc-installer:${owner}:end -->`;
144
+ return current.replace(new RegExp(`${start}[\\s\\S]*?${end}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").trimEnd();
145
+ };
98
146
  export const planUninstall = (root) => {
99
147
  const project = resolve(root);
100
148
  if (!hasOwnedManifest(project))
101
149
  throw new Error("No aidlc-workflow ownership manifest found; refusing to remove files.");
102
150
  const installed = readManifest(project);
103
151
  const currentSpecs = [...coreWorkflowFiles(), ...resolvedAdapters().flatMap((adapter) => adapter.files()).map((file) => ({ ...file, ownershipClass: "managed" }))];
104
- const specs = installed ? Object.entries(installed.files).map(([path, item]) => currentSpecs.find((spec) => spec.path === path) ?? ({ path, owner: item.owner, strategy: item.strategy, content: "", ownershipClass: "managed" })) : currentSpecs;
105
- const plan = specs.map((spec) => {
106
- const target = safeTarget(project, spec.path);
107
- if (!existsSync(target))
108
- return { ...spec, action: "skip", reason: "not installed" };
109
- const current = readFileSync(target, "utf8");
110
- if (spec.strategy === "managed-block") {
111
- const block = managedBlock(spec, current);
112
- if (!block)
113
- return { ...spec, action: "skip", reason: "owned block not found" };
114
- const installedHash = installed?.files[spec.path]?.hash ?? contentHash(spec.content.trimEnd());
115
- if (contentHash(block) !== installedHash)
116
- return { ...spec, action: "preserve", reason: "managed block was modified" };
117
- const start = `<!-- aidlc-installer:${spec.owner}:start -->`;
118
- const end = `<!-- aidlc-installer:${spec.owner}:end -->`;
119
- const next = current.replace(new RegExp(`${start}[\\s\\S]*?${end}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").trimEnd();
120
- return next ? { ...spec, content: `${next}\n`, action: "update", reason: "remove owned prompt block" } : { ...spec, content: "", action: "delete", reason: "remove owned prompt file" };
152
+ const entries = installed
153
+ ? Object.entries(installed.files).map(([key, item]) => {
154
+ const separator = key.lastIndexOf("#");
155
+ const path = separator > 0 ? key.slice(0, separator) : key;
156
+ const owner = separator > 0 ? key.slice(separator + 1) : item.owner;
157
+ const spec = currentSpecs.find((candidate) => candidate.path === path && (candidate.strategy !== "managed-block" || candidate.owner === owner)) ?? { path, owner, strategy: item.strategy, content: "", ownershipClass: "managed" };
158
+ return { spec, hash: item.hash };
159
+ })
160
+ : currentSpecs.map((spec) => ({ spec }));
161
+ const grouped = new Map();
162
+ for (const entry of entries)
163
+ grouped.set(entry.spec.path, [...(grouped.get(entry.spec.path) ?? []), entry]);
164
+ const plan = [];
165
+ for (const [path, group] of grouped) {
166
+ const target = safeTarget(project, path);
167
+ const exists = existsSync(target);
168
+ const current = exists ? readFileSync(target, "utf8") : "";
169
+ if (!exists) {
170
+ plan.push({ ...group[0].spec, action: "skip", reason: "not installed" });
171
+ continue;
172
+ }
173
+ if (!group.some((entry) => entry.spec.strategy === "managed-block")) {
174
+ const { spec, hash } = group[0];
175
+ const installedHash = hash ?? contentHash(spec.content);
176
+ plan.push(contentHash(current) === installedHash ? { ...spec, action: "delete", reason: "remove unchanged managed file" } : { ...spec, action: "preserve", reason: "managed file was modified" });
177
+ continue;
121
178
  }
122
- const installedHash = installed?.files[spec.path]?.hash ?? contentHash(spec.content);
123
- return contentHash(current) === installedHash ? { ...spec, action: "delete", reason: "remove unchanged managed file" } : { ...spec, action: "preserve", reason: "managed file was modified" };
124
- });
179
+ let next = current;
180
+ let modified;
181
+ for (const { spec, hash } of group) {
182
+ const block = managedBlock(spec, next);
183
+ // A v0.2.1 single-marker block cannot be removed safely; retain ownership instead of orphaning it.
184
+ if (!block) {
185
+ if (next.includes(`<!-- aidlc-installer:${spec.owner} -->`))
186
+ modified = spec;
187
+ continue;
188
+ }
189
+ if (contentHash(block) !== (hash ?? contentHash(spec.content.trimEnd()))) {
190
+ modified = spec;
191
+ break;
192
+ }
193
+ next = removeManagedBlock(next, spec.owner);
194
+ }
195
+ const ownerSpec = group[0].spec;
196
+ const legacyResidue = group.find(({ spec }) => next.includes(`<!-- aidlc-installer:${spec.owner} -->`));
197
+ if (modified)
198
+ plan.push({ ...modified, action: "preserve", reason: "managed block was modified" });
199
+ else if (legacyResidue)
200
+ plan.push({ ...legacyResidue.spec, action: "preserve", reason: "legacy prompt marker requires manual removal" });
201
+ else if (next === current)
202
+ plan.push({ ...ownerSpec, action: "skip", reason: "owned block not found" });
203
+ else if (next)
204
+ plan.push({ ...ownerSpec, content: `${next}\n`, action: "update", reason: "remove owned prompt block" });
205
+ else
206
+ plan.push({ ...ownerSpec, content: "", action: "delete", reason: "remove owned prompt file" });
207
+ }
125
208
  plan.push({ path: ".agents/aidlc/manifest.json", owner: "aidlc-core", content: "", ownershipClass: "managed", action: plan.some((item) => item.action === "preserve") ? "preserve" : "delete", reason: plan.some((item) => item.action === "preserve") ? "retained while modified managed files remain" : "remove ownership manifest" });
126
209
  return plan;
127
210
  };
@@ -160,13 +243,14 @@ export const doctor = (root, strict = false) => {
160
243
  catch (error) {
161
244
  return `ERROR: local config/state is invalid: ${error instanceof Error ? error.message : String(error)}`;
162
245
  }
163
- const modified = Object.entries(manifest.files).filter(([path, item]) => {
164
- const target = safeTarget(root, path);
165
- if (!existsSync(target))
166
- return true;
167
- const spec = { owner: item.owner, strategy: item.strategy };
168
- return managedContentHash(spec, readFileSync(target, "utf8")) !== item.hash;
169
- }).map(([path]) => path);
246
+ const modified = [...new Set(Object.entries(manifest.files).filter(([key, item]) => {
247
+ const separator = key.lastIndexOf("#");
248
+ const target = safeTarget(root, separator > 0 ? key.slice(0, separator) : key);
249
+ if (!existsSync(target))
250
+ return true;
251
+ const spec = { owner: item.owner, strategy: item.strategy };
252
+ return managedContentHash(spec, readFileSync(target, "utf8")) !== item.hash;
253
+ }).map(([key]) => key.includes("#") ? key.slice(0, key.lastIndexOf("#")) : key))];
170
254
  if (modified.length)
171
255
  return `${strict ? "ERROR" : "WARN"}: managed integrity differs: ${modified.join(", ")}`;
172
256
  return `OK: local workflow ${manifest.packageVersion} is valid; remote updates are disabled.`;
@@ -10,7 +10,7 @@ export const mergeManagedBlock = (spec, current) => {
10
10
  const end = `<!-- aidlc-installer:${spec.owner}:end -->`;
11
11
  const block = spec.content.trimEnd();
12
12
  const matches = new RegExp(`${start}[\\s\\S]*?${end}\\n?`, "g");
13
- const withoutOwnedBlocks = current.replace(matches, "").replace(/\n{3,}/g, "\n\n").trimEnd();
13
+ const withoutOwnedBlocks = current.replace(matches, "").replace(/\n{3,}/g, "\n\n").trim();
14
14
  const next = `${withoutOwnedBlocks}${withoutOwnedBlocks ? "\n\n" : ""}${block}`;
15
15
  return `${next.trimEnd()}\n`;
16
16
  };
@@ -31,6 +31,17 @@ export interface InitOptions {
31
31
  dryRun: boolean;
32
32
  yes: boolean;
33
33
  force: boolean;
34
+ loop?: LoopConfig;
35
+ }
36
+ export type LoopMode = "off" | "local" | "fleet";
37
+ export declare const LOOP_PROVIDERS: readonly ["codex", "claude", "opencode"];
38
+ export type LoopAgent = (typeof LOOP_PROVIDERS)[number];
39
+ export interface LoopConfig {
40
+ mode: LoopMode;
41
+ agents?: {
42
+ lead: LoopAgent;
43
+ workers: Record<string, LoopAgent>;
44
+ };
34
45
  }
35
46
  export interface CommandSpec {
36
47
  command: string;
@@ -72,6 +83,7 @@ export interface ProjectConfig {
72
83
  agentState: Partial<Record<AgentId, StateMutationMode>>;
73
84
  tracker?: TrackerConfig;
74
85
  mcp: McpConfig;
86
+ loop?: LoopConfig;
75
87
  }
76
88
  export interface McpConfig {
77
89
  enabled: boolean;
@@ -127,6 +139,7 @@ export interface LessonDisposition {
127
139
  }
128
140
  export interface TaskState {
129
141
  id: string;
142
+ featureId?: string;
130
143
  title: string;
131
144
  type: "feature" | "bug" | "refactor" | "infra";
132
145
  phase: Phase;
@@ -146,6 +159,8 @@ export interface TaskState {
146
159
  id: string;
147
160
  label: string;
148
161
  status: "todo" | "in_progress" | "done" | "deferred";
162
+ dependsOn?: string[];
163
+ ownedPaths?: string[];
149
164
  }>;
150
165
  evidence: Evidence[];
151
166
  lessons?: LessonRecord[];
package/dist/src/model.js CHANGED
@@ -1 +1 @@
1
- export {};
1
+ export const LOOP_PROVIDERS = Object.freeze(["codex", "claude", "opencode"]);
@@ -1,5 +1,6 @@
1
- import type { McpConfig, Profile, ProjectConfig, RiskLevel } from "./model.js";
1
+ import type { LoopConfig, McpConfig, Profile, ProjectConfig, RiskLevel } from "./model.js";
2
2
  export declare const builtInProfiles: Readonly<Record<string, Profile>>;
3
+ export declare const validateLoopConfig: (value: unknown) => LoopConfig;
3
4
  export declare const validateProfile: (value: unknown, label?: string) => Profile;
4
5
  export declare const defaultConfig: () => ProjectConfig;
5
6
  export declare const loadProjectConfig: (root: string) => ProjectConfig;
@@ -24,5 +25,6 @@ export declare const resolveEffectiveConfig: (root: string, config: ProjectConfi
24
25
  maxChars: number;
25
26
  };
26
27
  mcp: McpConfig;
28
+ loop: LoopConfig;
27
29
  };
28
30
  export declare const includedRuleFiles: (root: string, patterns: string[]) => string[];
@@ -1,5 +1,8 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
3
+ import { LOOP_PROVIDERS } from "./model.js";
4
+ const loopProviderNames = `${LOOP_PROVIDERS.slice(0, -1).join(", ")}, or ${LOOP_PROVIDERS[LOOP_PROVIDERS.length - 1]}`;
5
+ const isLoopProvider = (value) => typeof value === "string" && LOOP_PROVIDERS.includes(value);
3
6
  const profile = (id, markers = []) => ({
4
7
  schemaVersion: 1,
5
8
  id: `topology/${id}`,
@@ -20,6 +23,23 @@ const object = (value, label) => {
20
23
  throw new Error(`${label} must be an object`);
21
24
  return value;
22
25
  };
26
+ export const validateLoopConfig = (value) => {
27
+ if (value === undefined)
28
+ return { mode: "off" };
29
+ const item = object(value, "config.loop");
30
+ if (Object.keys(item).some((key) => !["mode", "agents"].includes(key)) || typeof item.mode !== "string" || !["off", "local", "fleet"].includes(item.mode))
31
+ throw new Error("config.loop requires a valid mode and only supported fields");
32
+ if (item.agents === undefined)
33
+ return { mode: item.mode };
34
+ const agents = object(item.agents, "config.loop.agents");
35
+ if (Object.keys(agents).some((key) => !["lead", "workers"].includes(key)) || !isLoopProvider(agents.lead))
36
+ throw new Error(`config.loop.agents.lead must be ${loopProviderNames}`);
37
+ const workers = object(agents.workers, "config.loop.agents.workers");
38
+ for (const [id, provider] of Object.entries(workers))
39
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) || !isLoopProvider(provider))
40
+ throw new Error(`config.loop.agents.workers must map worker IDs to ${loopProviderNames}`);
41
+ return { mode: item.mode, agents: { lead: agents.lead, workers: workers } };
42
+ };
23
43
  const strings = (value, label) => {
24
44
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
25
45
  throw new Error(`${label} must be a string array`);
@@ -108,7 +128,8 @@ export const defaultConfig = () => ({
108
128
  risk: { default: "normal" },
109
129
  context: { maxChars: 16_000 },
110
130
  agentState: {},
111
- mcp: { enabled: false }
131
+ mcp: { enabled: false },
132
+ loop: { mode: "off" }
112
133
  });
113
134
  export const loadProjectConfig = (root) => {
114
135
  const path = join(resolve(root), ".agents/config.json");
@@ -140,7 +161,8 @@ export const loadProjectConfig = (root) => {
140
161
  context: { maxChars },
141
162
  agentState,
142
163
  tracker: tracker(item.tracker),
143
- mcp: mcp(item.mcp)
164
+ mcp: mcp(item.mcp),
165
+ loop: validateLoopConfig(item.loop)
144
166
  };
145
167
  };
146
168
  const readLocalProfile = (root, id) => {
@@ -183,7 +205,8 @@ export const resolveEffectiveConfig = (root, config) => {
183
205
  rules: { include: stable([...profiles.flatMap((item) => item.rules?.include ?? []), ...config.rules.include]) },
184
206
  risk: config.risk,
185
207
  context: config.context,
186
- mcp: config.mcp
208
+ mcp: config.mcp,
209
+ loop: config.loop ?? { mode: "off" }
187
210
  };
188
211
  };
189
212
  export const includedRuleFiles = (root, patterns) => {
@@ -73,6 +73,13 @@ export interface NextAction {
73
73
  }>;
74
74
  reason: string;
75
75
  }
76
+ export declare const readyTaskItems: (task: TaskState) => {
77
+ ready: string[];
78
+ blocked: Array<{
79
+ id: string;
80
+ waitingOn: string[];
81
+ }>;
82
+ };
76
83
  export declare const nextAction: (task: TaskState, root?: string) => NextAction;
77
84
  export declare const handoffTask: (state: WorkflowState, id: string, kind: HandoffKind, reason: string, source: string, recordedAt?: string) => TaskState;
78
85
  export declare const closeTask: (state: WorkflowState, id: string, reason: string, source: string, recordedAt?: string) => TaskState;
package/dist/src/state.js CHANGED
@@ -241,8 +241,11 @@ const validateTask = (id, task) => {
241
241
  throw new Error(`Invalid task language, risk, or areas: ${id}`);
242
242
  if (task.decisions.some((item) => !item.id || !item.label || !["unresolved", "approved", "changed", "dropped"].includes(item.status) || (item.status === "changed" && !item.resolution)))
243
243
  throw new Error(`Invalid decisions: ${id}`);
244
+ if (task.featureId !== undefined && !idPattern.test(task.featureId))
245
+ throw new Error(`Invalid feature id: ${id}`);
244
246
  if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status)))
245
247
  throw new Error(`Invalid execution tasks: ${id}`);
248
+ readyTaskItems(task);
246
249
  if (task.evidence.some((item) => !["approval", "spec", "test", "lint", "review", "diagnostic"].includes(item.kind) || !["pass", "fail", "skip"].includes(item.result) || !item.source || !iso(item.recordedAt) || (item.area !== undefined && !text(item.area)) || (item.gate !== undefined && !gates.includes(item.gate)) || (item.kind === "approval" && (!item.gate || item.gate === "none"))))
247
250
  throw new Error(`Invalid evidence: ${id}`);
248
251
  const lessonIds = new Set();
@@ -551,6 +554,48 @@ export const transitionTask = (state, id, target) => {
551
554
  task.updatedAt = new Date().toISOString();
552
555
  return task;
553
556
  };
557
+ export const readyTaskItems = (task) => {
558
+ const byId = new Map(task.tasks.map((item) => [item.id, item]));
559
+ if (byId.size !== task.tasks.length)
560
+ throw new Error(`Duplicate execution item: ${task.id}`);
561
+ const visiting = new Set();
562
+ const visited = new Set();
563
+ const visit = (id) => {
564
+ if (visiting.has(id))
565
+ throw new Error(`Cyclic execution dependency: ${task.id}/${id}`);
566
+ if (visited.has(id))
567
+ return;
568
+ visiting.add(id);
569
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
570
+ if (dep === id || !byId.has(dep))
571
+ throw new Error(`Unknown execution dependency: ${task.id}/${id}/${dep}`);
572
+ visit(dep);
573
+ }
574
+ visiting.delete(id);
575
+ visited.add(id);
576
+ };
577
+ for (const item of task.tasks) {
578
+ if (item.dependsOn !== undefined && (!Array.isArray(item.dependsOn) || new Set(item.dependsOn).size !== item.dependsOn.length || item.dependsOn.some((dep) => typeof dep !== "string" || !dep)))
579
+ throw new Error(`Invalid execution dependencies: ${task.id}/${item.id}`);
580
+ if (item.ownedPaths !== undefined && (!Array.isArray(item.ownedPaths) || new Set(item.ownedPaths).size !== item.ownedPaths.length || item.ownedPaths.some((path) => typeof path !== "string" || !path || isAbsolute(path) || path === ".." || path.startsWith("../") || path.includes("\\") || path.split("/").includes(".."))))
581
+ throw new Error(`Invalid owned paths: ${task.id}/${item.id}`);
582
+ visit(item.id);
583
+ }
584
+ const ready = [];
585
+ const blocked = [];
586
+ for (const item of task.tasks) {
587
+ const waitingOn = (item.dependsOn ?? []).filter((dep) => byId.get(dep)?.status !== "done");
588
+ if (item.status === "done" && waitingOn.length)
589
+ throw new Error(`Completed item has incomplete prerequisites: ${task.id}/${item.id}`);
590
+ if (item.status !== "todo" && item.status !== "in_progress")
591
+ continue;
592
+ if (waitingOn.length)
593
+ blocked.push({ id: item.id, waitingOn });
594
+ else
595
+ ready.push(item.id);
596
+ }
597
+ return { ready, blocked };
598
+ };
554
599
  export const nextAction = (task, root) => {
555
600
  const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
556
601
  const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
@@ -579,9 +624,12 @@ export const nextAction = (task, root) => {
579
624
  return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase plan${rootSuffix}`, reason: "Invalid premature G1 wait; resolve every decision before presenting G1" };
580
625
  if (task.status === "blocked_on_user" && task.gate === "G2_codereview" && (task.tasks.some((item) => !["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task))) {
581
626
  const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
582
- const item = open.find((entry) => entry.status === "in_progress") ?? open[0];
627
+ const ready = new Set(readyTaskItems(task).ready);
628
+ const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
583
629
  if (item)
584
630
  return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Invalid premature G2 wait; continue build item ${item.id}` };
631
+ if (open.length)
632
+ return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
585
633
  return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "Invalid premature G2 wait; continue verification and adversarial review before presenting G2" };
586
634
  }
587
635
  if (task.status === "blocked_on_user" && task.gate === "none")
@@ -590,9 +638,12 @@ export const nextAction = (task, root) => {
590
638
  return { classification: "await_user", phase: task.phase, gate: task.gate, command: `${stateCommand} gate approve ${task.id} --gate ${task.gate} --source <explicit-user-approval>${rootSuffix}`, reason: `Explicit human approval is required at ${task.gate}` };
591
639
  if (task.phase === "build") {
592
640
  const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
593
- const item = open.find((entry) => entry.status === "in_progress") ?? open[0];
641
+ const ready = new Set(readyTaskItems(task).ready);
642
+ const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
594
643
  if (item)
595
644
  return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Continue build item ${item.id}; ${open.length} actionable item(s) remain before verification, review, and G2` };
645
+ if (open.length)
646
+ return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
596
647
  return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "All build items are terminal; continue verification, adversarial review, and G2 preparation" };
597
648
  }
598
649
  return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase ${task.phase}${rootSuffix}`, reason: `Continue ${task.phase} until the next human gate, real blocker, or completion` };
@@ -752,7 +803,7 @@ export const renderWorkplan = (task) => {
752
803
  const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None";
753
804
  const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None";
754
805
  const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n");
755
- return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Gate G1 — approve before build)\n${decisions}\n\n## 🧩 Tasks (Gate G2 — build execution)\n${tasks}\n`;
806
+ return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`;
756
807
  };
757
808
  export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => {
758
809
  for (const task of selected) {
@@ -1,9 +1,9 @@
1
- import type { FileSpec, PlannedWrite, WorkflowState } from "./model.js";
1
+ import type { FileSpec, LoopConfig, PlannedWrite, WorkflowState } from "./model.js";
2
2
  export declare const migrateLegacyBoard: (root: string) => {
3
3
  state: WorkflowState;
4
4
  files: FileSpec[];
5
5
  };
6
- export declare const planUpgrade: (root: string) => PlannedWrite[];
6
+ export declare const planUpgrade: (root: string, loop?: LoopConfig) => PlannedWrite[];
7
7
  export interface UpgradeResult {
8
8
  backup: string;
9
9
  changed: number;