@felixdotgo/aidlc-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,320 @@
1
+ import { taskContainers } from "../containers.js";
2
+ import { ARTIFACT_NAMES, artifactPayload, pendingFeedback, readUiState, readUiTask, taskGatePayload } from "../data.js";
3
+ import { escapeHtml } from "../http.js";
4
+ import { markdownToHtml } from "../markdown.js";
5
+ import { uiMode } from "../options.js";
6
+ import { DECISION_TONE, feedbackPill, GATE_LABELS, gateLabel, gatePill, ITEM_TONE, phaseStepper, pill, relativeTime, renderGateViewHtml, RESULT_TONE, riskPill, statusPill } from "./components.js";
7
+ import { changesHref } from "./changes.js";
8
+ import { liveRegion, pageHead, pageScripts, renderHeader, renderModals, reviewQueue } from "./layout.js";
9
+ /* ---------------------------------------------------------------------------
10
+ * Task detail (page and side-panel fragment share the same body).
11
+ * ------------------------------------------------------------------------- */
12
+ /** The artifact a reviewer most likely needs at each gate opens by default. */
13
+ const GATE_ARTIFACT = { G0_confirm: "intent", G1_review: "design", G2_codereview: "workplan" };
14
+ /** Lines kept by the task page's live log and shown by the side panel's compact one. */
15
+ const FULL_LOG_LINES = 2000;
16
+ export const DRAWER_LOG_LINES = 20;
17
+ /** Task id as a copy-to-clipboard button (handled by the delegated \`[data-copy]\` click in common.js). */
18
+ export const copyIdButton = (id) => `<button type="button" class="ad-copy" data-copy="${escapeHtml(id)}" title="Copy task ID" aria-label="Copy task ID ${escapeHtml(id)}"><code>${escapeHtml(id)}</code><svg aria-hidden="true" viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M10.5 5V3.5A1.5 1.5 0 0 0 9 2H3.5A1.5 1.5 0 0 0 2 3.5V9a1.5 1.5 0 0 0 1.5 1.5H5"/></svg></button>`;
19
+ const panel = (title, body, extra = "", flush = false) => `<section class="ad-panel" data-key="panel:${escapeHtml(title)}"><header class="ad-panel-head"><h2>${escapeHtml(title)}</h2>${extra}</header><div class="ad-panel-body${flush ? " is-flush" : ""}">${body}</div></section>`;
20
+ /* ---------------------------------------------------------------------------
21
+ * Containers panel (worker containers recorded under the task's runtime data)
22
+ * ------------------------------------------------------------------------- */
23
+ const UUID_PATTERN = /([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
24
+ /** `aidlc-worker-3f2a1b9c-…` → `3f2a1b9c`; names without a uuid are shown whole. */
25
+ export const shortContainerName = (name) => UUID_PATTERN.exec(name)?.[1] ?? name;
26
+ /** `endedAt - startedAt` as `4s`, `3m 12s`, or `1h 05m`; empty when either timestamp is unusable. */
27
+ export const durationText = (startedAt, endedAt) => {
28
+ const start = Date.parse(startedAt ?? "");
29
+ const end = Date.parse(endedAt ?? "");
30
+ if (Number.isNaN(start) || Number.isNaN(end))
31
+ return "";
32
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
33
+ if (seconds < 60)
34
+ return `${seconds}s`;
35
+ if (seconds < 3600)
36
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
37
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor((seconds % 3600) / 60)).padStart(2, "0")}m`;
38
+ };
39
+ const containerStatusPill = (entry) => {
40
+ if (entry.running)
41
+ return pill("running", "is-success is-live", "the container is running");
42
+ if (!entry.endedAt)
43
+ return pill("unknown", "is-warning", "no end recorded and docker does not list the container");
44
+ if (entry.exitCode === null || entry.exitCode === undefined)
45
+ return pill("signal", "is-warning", "ended without an exit code (signal or finalized)");
46
+ return pill(`exited ${entry.exitCode}`, entry.exitCode === 0 ? "is-ghost" : "is-danger", `exit code ${entry.exitCode}`);
47
+ };
48
+ /** The Containers panel: a table of the task's recorded worker containers (newest first) and the log viewer. */
49
+ export const renderContainersPanel = (entries, docker) => {
50
+ const rows = [...entries].reverse().map((entry) => {
51
+ const logButton = entry.local
52
+ ? `<button type="button" class="button is-small" data-container-log="${escapeHtml(entry.assignmentId)}" data-container-name="${escapeHtml(entry.name)}" data-container-running="${entry.running ? "1" : "0"}" title="Show the raw container output">Log</button>`
53
+ : `<button type="button" class="button is-small" disabled title="log is on another host">Log</button>`;
54
+ return `<tr data-key="container:${escapeHtml(entry.name)}:${escapeHtml(String(entry.turn ?? ""))}">
55
+ <td><code title="${escapeHtml(entry.name)}">${escapeHtml(shortContainerName(entry.name))}</code>${entry.local || !entry.host ? "" : `<div class="is-size-7 has-text-grey" title="recorded on another host">${escapeHtml(entry.host)}</div>`}</td>
56
+ <td class="is-size-7">${escapeHtml(entry.workerId ?? "")}${entry.agent ? `<div class="has-text-grey">${escapeHtml(entry.agent)}</div>` : ""}</td>
57
+ <td><code>${escapeHtml(entry.itemId ?? "")}</code></td>
58
+ <td class="is-size-7">${entry.turn === undefined ? "" : escapeHtml(String(entry.turn))}</td>
59
+ <td>${containerStatusPill(entry)}</td>
60
+ <td class="is-size-7 has-text-grey">${relativeTime(entry.startedAt)}${entry.endedAt ? `<div title="duration">${escapeHtml(durationText(entry.startedAt, entry.endedAt))}</div>` : ""}</td>
61
+ <td>${logButton}</td>
62
+ </tr>`;
63
+ });
64
+ const table = rows.length
65
+ ? `<table class="table ad-table is-fullwidth"><thead><tr>${["container", "worker", "item", "turn", "status", "started", ""].map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead><tbody>${rows.join("\n")}</tbody></table>`
66
+ : `<div class="ad-empty-line"><p>No worker containers recorded for this task</p><p class="is-size-7 mt-1">Containers appear here while the loop builds the task's work items; a task driven from the terminal without the loop never starts any.</p></div>`;
67
+ /* The viewer is client-owned (detail.js): live updates never morph inside it, so an open log survives polling. */
68
+ const viewer = `<section class="ad-terminal ad-container-log" id="container-log" data-live-ignore hidden>
69
+ <div class="ad-terminal-head"><strong>Container log</strong><span class="ad-log-state" data-container-log-name></span><span class="ad-log-state" data-container-log-state></span>
70
+ <div class="ad-terminal-tools"><button type="button" data-container-follow aria-pressed="true" title="Re-fetch the tail while the container runs">Follow</button><button type="button" data-container-close>Close</button></div>
71
+ </div>
72
+ <pre class="log-panel" data-container-log-body role="log" aria-live="off"></pre>
73
+ </section>`;
74
+ const tools = `<span class="ad-panel-tools">${pill(`docker · ${docker}`, docker === "ok" ? "is-ghost" : "is-warning", docker === "ok" ? "docker ps answers; running state is live" : "docker did not answer; running state follows the index only", false)}<span class="ad-count">${entries.length}</span></span>`;
75
+ return panel("Containers", table + viewer, tools, true);
76
+ };
77
+ /**
78
+ * Task detail body shared by the full page (`detail` live region), the side
79
+ * panel (`fragment`, `drawer` live region), and `/api/live`. Undefined when the
80
+ * task does not exist. Keyed panels (`id`/`data-key`) let the client morph them
81
+ * in place: open artifacts, typed feedback, and the live log survive updates.
82
+ */
83
+ export const renderTaskBody = async (options, state, id, fragment, maxAgeMs = 0) => {
84
+ const task = readUiTask(options, id, state);
85
+ const fleet = uiMode(options) === "fleet";
86
+ if (!task)
87
+ return undefined;
88
+ const waiting = task.status === "blocked_on_user" && task.gate !== "none";
89
+ // A gate whose structural checks now fail (e.g. an artifact edited after the
90
+ // wait was set) makes gateView throw; show the diagnostics instead of a 500.
91
+ let gateBlock = "";
92
+ // At code review the reviewer needs the diff: link the changes page from the gate panel.
93
+ const gateChanges = task.gate === "G2_codereview"
94
+ ? `<p class="mt-3"><a class="button is-small is-link is-light" href="${changesHref(task.id)}" data-changes-link="gate">View code changes →</a></p>`
95
+ : "";
96
+ if (waiting) {
97
+ try {
98
+ const gate = await taskGatePayload(options, id, state, maxAgeMs);
99
+ if (gate.ok && gate.view)
100
+ gateBlock = renderGateViewHtml(gate.view, gateChanges);
101
+ else if (gate.ok && gate.snapshot)
102
+ gateBlock = `<section class="ad-panel is-warning" data-key="gate-view"><header class="ad-panel-head"><h2>${escapeHtml(gateLabel(task.gate))} · review snapshot</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(gate.snapshot.id ?? "")}</span></header><div class="ad-panel-body"><div class="content artifact">${markdownToHtml(gate.snapshot.reviewText ?? "")}</div>${gateChanges}</div></section>`;
103
+ else if (fleet)
104
+ gateBlock = `<section class="ad-panel is-warning" data-key="gate-view"><header class="ad-panel-head"><h2>${escapeHtml(gateLabel(task.gate))}</h2></header><div class="ad-panel-body"><p>${escapeHtml(gate.error ?? "No review snapshot yet")}</p></div></section>`;
105
+ }
106
+ catch (error) {
107
+ const problems = (error instanceof Error ? error.message : String(error)).split("; ").filter(Boolean);
108
+ gateBlock = `<section class="ad-panel is-danger" data-key="gate-view">
109
+ <header class="ad-panel-head"><span aria-hidden="true">⚠</span><h2>${escapeHtml(gateLabel(task.gate))} · gate check failing</h2><span class="ad-pill is-danger ad-panel-tools">${escapeHtml(task.gate)}</span></header>
110
+ <div class="ad-panel-body">
111
+ <p class="mb-3">The task waits at this gate, but its structural checks no longer pass. Approval will be rejected until these are fixed:</p>
112
+ <ul class="ad-plain-list">${problems.map((problem) => `<li>${pill("ERROR", "is-danger")} ${escapeHtml(problem)}</li>`).join("")}</ul>
113
+ <p class="is-size-7 has-text-grey mt-3">Re-check with <code>node .agents/aidlc/scripts/gate-check.mjs ${escapeHtml(task.id)} --gate ${escapeHtml(task.gate)}</code>.</p>
114
+ </div>
115
+ </section>`;
116
+ }
117
+ }
118
+ /* Pending feedback: `Cancelled gate wait` evidence with no later approval of
119
+ * that gate. While the task is active the lead is revising for it; while it
120
+ * waits again, the reviewer should check the revision reflects it. */
121
+ const pending = pendingFeedback(task);
122
+ const pendingBlock = !fragment && pending.length
123
+ ? `<section class="ad-panel is-info" id="feedback-pending-panel">
124
+ <header class="ad-panel-head"><h2>Feedback pending</h2><span class="ad-pill is-info ad-panel-tools">${pending.length}</span></header>
125
+ <div class="ad-panel-body">
126
+ <p class="mb-2">${waiting
127
+ ? "This gate was re-presented after human feedback. Check that the review artifact reflects it before approving; approving records the feedback as handled."
128
+ : "The task stays at its gate. The loop lead is revising the review artifact for this feedback and will re-present the same gate."}</p>
129
+ <ul class="ad-plain-list">${pending.map((entry) => `<li>${pill(gateLabel(entry.gate), "is-info", `gate: ${entry.gate}`, false)} ${escapeHtml(entry.text)} <span class="is-size-7 has-text-grey">· ${relativeTime(entry.recordedAt)}</span></li>`).join("")}</ul>
130
+ </div>
131
+ </section>`
132
+ : "";
133
+ /* Feedback never needs the fleet reviewer credential (it never approves), so
134
+ * the block renders in both modes whenever the task waits at a gate. */
135
+ const feedbackBlock = !fragment && waiting
136
+ ? `<section class="ad-panel is-info" id="feedback-panel" data-gate="${escapeHtml(task.gate)}" data-gate-label="${escapeHtml(gateLabel(task.gate))}">
137
+ <header class="ad-panel-head"><h2>Feedback · ${escapeHtml(gateLabel(task.gate))}</h2><span class="ad-pill is-ghost ad-panel-tools">stays at this gate</span></header>
138
+ <div class="ad-panel-body">
139
+ <p class="mb-2">Not ready to approve? Send guidance back to the loop. The task stays at <strong>${escapeHtml(gateLabel(task.gate))}</strong>; the lead revises the review artifact and re-presents this gate.</p>
140
+ <div class="field">
141
+ <label class="label is-small" for="feedback">Feedback <span class="has-text-grey has-text-weight-normal">(required, sent to the loop lead)</span></label>
142
+ <div class="control"><textarea id="feedback" class="textarea" rows="3" placeholder="e.g. the scope misses the fleet path; split item B; the assumption about X is wrong"></textarea></div>
143
+ </div>
144
+ <div class="is-flex is-align-items-center" style="gap:0.75rem">
145
+ <button id="feedback-button" class="button is-info">✎ Send feedback (stay at gate)</button>
146
+ <span id="feedback-result" class="help m-0" role="status"></span>
147
+ </div>
148
+ </div>
149
+ </section>`
150
+ : "";
151
+ const approveBlock = !fragment && waiting && fleet && !options.reviewerToken
152
+ ? `<section class="ad-panel" id="approve-panel">
153
+ <header class="ad-panel-head"><h2>Approve · ${escapeHtml(gateLabel(task.gate))}</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(task.gate)}</span></header>
154
+ <div class="ad-panel-body">
155
+ <p class="mb-2">This dashboard has no fleet reviewer credential. Approve from a reviewer terminal, or restart <code>aidlc-kit ui</code> with <code>AIDLC_FLEET_REVIEWER_TOKEN</code> or <code>--reviewer-token-file</code>. Feedback above needs no reviewer credential.</p>
156
+ <pre class="is-size-7">node .agents/aidlc/scripts/remote-state.mjs gate inbox
157
+ node .agents/aidlc/scripts/remote-state.mjs gate approve ${escapeHtml(task.id)} --snapshot &lt;snapshot-id&gt; --source "&lt;explicit approval&gt;"</pre>
158
+ </div>
159
+ </section>`
160
+ : !fragment && waiting
161
+ ? `<section class="ad-panel is-success" id="approve-panel" data-gate="${escapeHtml(task.gate)}" data-gate-label="${escapeHtml(gateLabel(task.gate))}">
162
+ <header class="ad-panel-head"><h2>Approve · ${escapeHtml(gateLabel(task.gate))}</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(task.gate)}</span></header>
163
+ <div class="ad-panel-body">
164
+ <p class="mb-2">Approving moves the task to the next phase. To request changes instead, use <strong>Feedback</strong> above — the note below is provenance only, not guidance for the loop.</p>
165
+ ${pending.length ? `<p class="help is-warning mb-3" id="approve-pending-warning">⚠ ${pending.length} feedback item${pending.length === 1 ? "" : "s"} still pending — make sure the revised review addresses ${pending.length === 1 ? "it" : "them"} before approving.</p>` : ""}
166
+ <div class="field">
167
+ <label class="label is-small" for="source">Approval note <span class="has-text-grey has-text-weight-normal">(required, recorded as provenance)</span></label>
168
+ <div class="control"><textarea id="source" class="textarea" rows="2" placeholder="e.g. reviewed intent and scope — ok"></textarea></div>
169
+ </div>
170
+ <div class="is-flex is-align-items-center" style="gap:0.75rem">
171
+ <button id="approve-button" class="button is-success">✓ Approve</button>
172
+ <span id="approve-result" class="help m-0" role="status"></span>
173
+ </div>
174
+ </div>
175
+ </section>`
176
+ : "";
177
+ /* Confirmation dialog: the only control that sends the approve request. */
178
+ const approveModal = !fragment && waiting && !(fleet && !options.reviewerToken)
179
+ ? `<div id="approve-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="approve-modal-title" data-live-preserve="class">
180
+ <div class="modal-background" data-close></div>
181
+ <div class="modal-card">
182
+ <header class="modal-card-head">
183
+ <p class="modal-card-title" id="approve-modal-title">Approve ${escapeHtml(gateLabel(task.gate))}?</p>
184
+ <button class="delete" aria-label="close" data-close></button>
185
+ </header>
186
+ <section class="modal-card-body">
187
+ <p>This approves <strong>${escapeHtml(gateLabel(task.gate))}</strong> (<code>${escapeHtml(task.gate)}</code>) for <code>${escapeHtml(task.id)}</code> and moves the task to the next phase. It cannot be undone from the dashboard.</p>
188
+ <p class="mt-2">To request changes instead, cancel and use <strong>Feedback</strong> — the task then stays at this gate.</p>
189
+ <p class="is-size-7 has-text-grey mt-3 mb-1">Approval note</p>
190
+ <blockquote id="approve-modal-note" class="box is-size-7 py-2 px-3 mb-0"></blockquote>
191
+ </section>
192
+ <footer class="modal-card-foot">
193
+ <button class="button" data-close data-autofocus>Cancel</button>
194
+ <button id="approve-confirm" class="button is-success">✓ Confirm approve</button>
195
+ </footer>
196
+ </div>
197
+ </div>`
198
+ : "";
199
+ const openArtifact = waiting ? GATE_ARTIFACT[task.gate] : undefined;
200
+ const artifacts = ARTIFACT_NAMES.map((name) => {
201
+ const artifactPath = task.artifacts?.[name];
202
+ if (!artifactPath)
203
+ return "";
204
+ const payload = artifactPayload(options.root, id, name, task);
205
+ const content = payload.ok && payload.content !== undefined
206
+ ? `<div class="content artifact">${markdownToHtml(payload.content)}</div>`
207
+ : `<p class="help is-danger px-5 pb-4">${escapeHtml(payload.error ?? "unavailable")}</p>`;
208
+ return `<details class="ad-artifact" data-key="artifact:${name}"${name === openArtifact ? " open" : ""}><summary>${escapeHtml(name)}<code>${escapeHtml(artifactPath)}</code></summary>${content}</details>`;
209
+ }).join("\n");
210
+ const table = (head, rows, empty) => rows.length
211
+ ? `<table class="table ad-table is-fullwidth"><thead><tr>${head.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead><tbody>${rows.join("\n")}</tbody></table>`
212
+ : `<p class="ad-empty-line">${escapeHtml(empty)}</p>`;
213
+ const decisions = table(["decision", "status", "resolution"], task.decisions.map((decision) => `<tr><td><code>${escapeHtml(decision.id)}</code><div class="is-size-7 has-text-grey mt-1">${escapeHtml(decision.label ?? "")}</div></td><td>${pill(decision.status, DECISION_TONE[decision.status] ?? "")}</td><td class="is-size-7">${escapeHtml(decision.resolution ?? "")}</td></tr>`), "No decisions recorded");
214
+ const doneItems = task.tasks.filter((item) => item.status === "done").length;
215
+ const workItems = table(["item", "status", "label"], task.tasks.map((item) => `<tr><td><code>${escapeHtml(item.id)}</code></td><td>${pill(item.status.replace("_", " "), ITEM_TONE[item.status] ?? "")}</td><td class="is-size-7">${escapeHtml(item.label ?? "")}</td></tr>`), "No work items yet");
216
+ const workProgress = task.tasks.length
217
+ ? `<div class="px-5 pt-4 pb-1"><progress class="progress is-small is-primary mb-0" value="${doneItems}" max="${task.tasks.length}" aria-label="work items done">${doneItems}/${task.tasks.length}</progress></div>`
218
+ : "";
219
+ const evidence = table(["kind", "result", "gate", "source", "recorded"], task.evidence.map((entry) => `<tr><td><strong>${escapeHtml(entry.kind)}</strong>${entry.area ? `<div class="is-size-7 has-text-grey">${escapeHtml(entry.area)}</div>` : ""}</td><td>${pill(entry.result, RESULT_TONE[entry.result] ?? "")}</td><td class="is-size-7">${escapeHtml(entry.gate ?? "")}</td><td class="is-size-7">${escapeHtml(entry.source ?? "")}${entry.detail ? `<div class="has-text-grey mt-1">${escapeHtml(entry.detail)}</div>` : ""}</td><td class="is-size-7 has-text-grey">${relativeTime(entry.recordedAt)}</td></tr>`), "No verification evidence yet");
220
+ /* Worker containers (full page only): the task's runtime record, the same in every
221
+ * mode; `docker ps` only refines `running` and is shared across live polls. */
222
+ let containersPanel = "";
223
+ if (!fragment) {
224
+ const containers = await taskContainers(options, id);
225
+ containersPanel = containers.status === 200 ? renderContainersPanel(containers.payload.containers, containers.payload.docker) : renderContainersPanel([], "unavailable");
226
+ }
227
+ const handoff = task.handoff
228
+ ? `<section class="ad-panel" data-key="handoff" style="box-shadow:0 0 0 1px var(--bulma-danger)"><header class="ad-panel-head"><h2>Handed off · ${escapeHtml(task.handoff.kind)}</h2></header><div class="ad-panel-body"><p>${escapeHtml(task.handoff.reason)}</p><p class="is-size-7 has-text-grey mt-2">Follow the handoff actions from <code>node .agents/aidlc/scripts/state.mjs task next ${escapeHtml(task.id)}</code>.</p></div></section>`
229
+ : "";
230
+ const meta = `<dl class="ad-meta">
231
+ <dt>Status</dt><dd>${statusPill(task.status, Boolean(task.handoff))}</dd>
232
+ <dt>Gate</dt><dd>${task.gate === "none" ? '<span class="has-text-grey">none</span>' : `${escapeHtml(gateLabel(task.gate))}<div class="is-size-7 has-text-grey">${escapeHtml(task.gate)}</div>`}</dd>
233
+ <dt>Risk</dt><dd>${riskPill(task.risk)}</dd>
234
+ <dt>Type</dt><dd>${escapeHtml(task.type)}</dd>
235
+ <dt>Language</dt><dd>${escapeHtml(task.language)}</dd>
236
+ <dt>Areas</dt><dd>${escapeHtml(task.areas.join(", "))}</dd>
237
+ <dt>Branch</dt><dd><code>${escapeHtml(task.branch)}</code></dd>
238
+ <dt>Created</dt><dd>${relativeTime(task.createdAt)}</dd>
239
+ <dt>Updated</dt><dd>${relativeTime(task.updatedAt)}</dd>
240
+ </dl>`;
241
+ /* Live log (assets/ui/live-log.js): client-owned SSE lines, so live updates never
242
+ * morph inside it ([data-live-ignore]). The full page gets the grouped log with its
243
+ * filter and Raw JSON switch; the side panel a compact tail of recent lines keyed by
244
+ * task, so switching tasks replaces it and a re-render keeps it. */
245
+ const logAttrs = (mode, limit) => `data-live-log data-log-mode="${mode}" data-log-limit="${limit}" data-task-id="${escapeHtml(task.id)}" data-gate-labels="${escapeHtml(JSON.stringify(GATE_LABELS))}"`;
246
+ const liveLog = fragment
247
+ ? `<section class="ad-terminal ad-live-log is-compact" data-key="drawer-log:${escapeHtml(task.id)}" data-live-ignore ${logAttrs("compact", DRAWER_LOG_LINES)}>
248
+ <div class="ad-terminal-head"><strong>Live log</strong><span class="ad-log-state" data-log-state>connecting…</span>
249
+ <div class="ad-terminal-tools"><a href="/tasks/${encodeURIComponent(task.id)}#live-log" data-log-open>Open full log →</a></div>
250
+ </div>
251
+ <div class="log-panel is-compact" data-log-body role="log" aria-live="off"><span class="ad-log-muted">connecting…</span></div>
252
+ </section>`
253
+ : `<section class="ad-terminal ad-live-log" id="live-log" data-live-ignore ${logAttrs("full", FULL_LOG_LINES)}>
254
+ <div class="ad-terminal-head"><span class="ad-lights" aria-hidden="true"><span></span><span></span><span></span></span><strong>Live log</strong><span id="log-state" class="ad-log-state" data-log-state>connecting…</span>
255
+ <div class="ad-terminal-tools">
256
+ <span class="ad-log-switch" role="group" aria-label="Which events to show"><button type="button" data-log-filter="important" aria-pressed="true" title="Hide bookkeeping events; show the first line of agent messages">Important</button><button type="button" data-log-filter="all" aria-pressed="false" title="Every event, agent messages in full">All</button></span>
257
+ <button type="button" data-log-raw aria-pressed="false" title="Show each event's original JSON">Raw JSON</button>
258
+ <button type="button" id="log-follow" data-log-follow aria-pressed="true">Follow</button><button type="button" id="log-clear" data-log-clear>Clear</button>
259
+ </div>
260
+ </div>
261
+ <div id="log" class="log-panel" data-log-body role="log" aria-live="off"><span class="ad-log-muted">connecting…</span></div>
262
+ </section>`;
263
+ /* The side panel stays read-only; when a human action is due, point at the
264
+ * full page that hosts it right at the top. */
265
+ const href = `/tasks/${encodeURIComponent(task.id)}`;
266
+ const drawerCta = !fragment ? ""
267
+ : task.handoff
268
+ ? `<div class="ad-callout is-danger ad-drawer-cta" data-key="drawer-cta"><span>Handed off · ${escapeHtml(task.handoff.kind)} — follow the handoff actions on the full page.</span><a class="button is-small is-danger" href="${href}">Open task →</a></div>`
269
+ : waiting
270
+ ? `<div class="ad-callout ad-drawer-cta" data-key="drawer-cta"><span>Waiting at <strong>${escapeHtml(gateLabel(task.gate))}</strong>${pending.length ? ` · ${pending.length} feedback pending` : ""} — send feedback or approve on the full page.</span><a class="button is-small is-warning" href="${href}" data-review-cta>Open review →</a></div>`
271
+ : "";
272
+ const body = `${drawerCta}<h1 class="ad-page-title">${escapeHtml(task.title)}</h1>
273
+ <div class="ad-page-sub">${copyIdButton(task.id)}${statusPill(task.status, Boolean(task.handoff))}${gatePill(task.gate)}${riskPill(task.risk)}${feedbackPill(pending.length)}</div>
274
+ ${phaseStepper(task.phase, task.gate, task.status, Boolean(task.handoff))}
275
+ ${fragment ? liveLog : ""}
276
+ <div class="columns is-desktop">
277
+ <div class="column is-8-desktop">
278
+ ${handoff}
279
+ ${pendingBlock}
280
+ ${gateBlock}
281
+ ${feedbackBlock}
282
+ ${approveBlock}
283
+ ${panel("Artifacts", artifacts || '<p class="ad-empty-line">No artifacts recorded</p>', `<span class="ad-count">${ARTIFACT_NAMES.filter((name) => task.artifacts?.[name]).length}</span>`, true)}
284
+ ${panel("Decisions", decisions, `<span class="ad-count">${task.decisions.length}</span>`, true)}
285
+ ${panel("Work items", workProgress + workItems, `<span class="ad-panel-tools"><a class="button is-small" href="${changesHref(task.id)}" data-changes-link="items" title="Code changes of this task">Changes</a><span class="ad-count">${doneItems}/${task.tasks.length}</span></span>`, true)}
286
+ ${containersPanel}
287
+ ${panel("Evidence", evidence, `<span class="ad-count">${task.evidence.length}</span>`, true)}
288
+ ${fragment ? "" : liveLog}
289
+ </div>
290
+ <aside class="column is-4-desktop">
291
+ <div class="ad-sticky">
292
+ ${panel("Details", meta)}
293
+ </div>
294
+ </aside>
295
+ </div>`;
296
+ return { task, html: fragment ? body : `${body}${approveModal}` };
297
+ };
298
+ export const taskNotFound = (options, id) => `<div class="ad-empty"><div class="ad-empty-icon" aria-hidden="true">🔍</div><h2>Task not found</h2><p><code>${escapeHtml(id)}</code> is not in the ${uiMode(options) === "fleet" ? "fleet" : "local"} canonical state.</p><a class="button" href="/">← Back to dashboard</a></div>`;
299
+ export const drawerNotFound = (id) => `<p class="has-text-danger">Task not found: ${escapeHtml(id)}</p>`;
300
+ export const renderTaskDetail = async (options, id, fragment = false) => {
301
+ const state = await readUiState(options);
302
+ const rendered = await renderTaskBody(options, state, id, fragment);
303
+ if (fragment)
304
+ return rendered ? rendered.html : drawerNotFound(id);
305
+ const reviews = reviewQueue(state);
306
+ if (!rendered)
307
+ return `${pageHead("Task not found · Loop")}${renderHeader(options.root, uiMode(options), reviews)}<main class="section ad-main"><div class="container is-fluid">${taskNotFound(options, id)}</div></main>${renderModals()}${pageScripts("common")}</body></html>`;
308
+ return `${pageHead(`${rendered.task.title} · Loop`, rendered.task.id)}
309
+ ${renderHeader(options.root, uiMode(options), reviews, rendered.task.id)}
310
+ <main class="section ad-main">
311
+ <div class="container is-fluid">
312
+ <a class="ad-breadcrumb" href="/">← Dashboard</a>
313
+ ${liveRegion("detail", rendered.html)}
314
+ </div>
315
+ </main>
316
+ ${renderModals()}
317
+ ${pageScripts("common", "detail", "live-log")}
318
+ </body>
319
+ </html>`;
320
+ };
@@ -12,7 +12,7 @@ const parseFrontmatter = (content) => {
12
12
  const match = content.match(/^---\n([\s\S]*?)\n---/);
13
13
  if (!match)
14
14
  return {};
15
- return Object.fromEntries(match[1].split("\n").flatMap((line) => {
15
+ return Object.fromEntries((match[1] ?? "").split("\n").flatMap((line) => {
16
16
  const separator = line.indexOf(":");
17
17
  return separator < 0 ? [] : [[line.slice(0, separator).trim(), line.slice(separator + 1).trim().replace(/^['"]|['"]$/g, "")]];
18
18
  }));
@@ -30,7 +30,7 @@ const legacyEvidence = (phase, recordedAt) => {
30
30
  /** Maps one legacy BOARD row to a canonical task, copying its prose files into the stable artifact layout. */
31
31
  const taskFromLegacyRow = (root, files, row, recordedAt) => {
32
32
  const [id, title, phaseRaw, gateRaw, statusRaw, branch, areasRaw, doc] = row;
33
- const oldDoc = safePath(root, doc);
33
+ const oldDoc = safePath(root, doc ?? "");
34
34
  const source = existsSync(oldDoc) ? readFileSync(oldDoc, "utf8") : "";
35
35
  const frontmatter = parseFrontmatter(source);
36
36
  const stableDir = `.agents/data/tasks/${id}`;
@@ -38,21 +38,21 @@ const taskFromLegacyRow = (root, files, row, recordedAt) => {
38
38
  const workplanPath = `${stableDir}/workplan.md`;
39
39
  if (source)
40
40
  files.push({ path: prosePath, owner: "aidlc-state", ownershipClass: "state", content: source });
41
- const oldWorkplan = doc.replace(/\.md$/, ".workplan.md");
41
+ const oldWorkplan = (doc ?? "").replace(/\.md$/, ".workplan.md");
42
42
  if (existsSync(safePath(root, oldWorkplan)))
43
43
  files.push({ path: `${stableDir}/legacy-workplan.md`, owner: "aidlc-state", ownershipClass: "state", content: readFileSync(safePath(root, oldWorkplan), "utf8") });
44
- const phase = ["clarify", "plan", "build", "wrap", "done"].includes(phaseRaw) ? phaseRaw : "clarify";
45
- const gate = ["none", "G0_confirm", "G1_review", "G2_codereview"].includes(gateRaw) ? gateRaw : "G0_confirm";
44
+ const phase = (phaseRaw !== undefined && ["clarify", "plan", "build", "wrap", "done"].includes(phaseRaw) ? phaseRaw : "clarify");
45
+ const gate = (gateRaw !== undefined && ["none", "G0_confirm", "G1_review", "G2_codereview"].includes(gateRaw) ? gateRaw : "G0_confirm");
46
46
  return {
47
- id,
48
- title,
49
- type: (["feature", "bug", "refactor", "infra"].includes(frontmatter.type) ? frontmatter.type : "infra"),
47
+ id: id ?? "",
48
+ title: title ?? "",
49
+ type: (["feature", "bug", "refactor", "infra"].includes(frontmatter.type ?? "") ? frontmatter.type : "infra"),
50
50
  phase,
51
51
  gate,
52
- status: (["active", "blocked_on_user", "paused", "done"].includes(statusRaw) ? statusRaw : "paused"),
52
+ status: (statusRaw !== undefined && ["active", "blocked_on_user", "paused", "done"].includes(statusRaw) ? statusRaw : "paused"),
53
53
  language: frontmatter.language === "en" ? "en" : "vi",
54
54
  risk: "normal",
55
- areas: areasRaw.split(",").map((area) => area.trim()).filter(Boolean),
55
+ areas: (areasRaw ?? "").split(",").map((area) => area.trim()).filter(Boolean),
56
56
  branch: branch || "—",
57
57
  artifacts: { intent: source ? prosePath : undefined, design: source ? prosePath : undefined, workplan: workplanPath },
58
58
  decisions: [],
@@ -1,5 +1,4 @@
1
1
  import type { LoopConfig, PlannedWrite } from "./model.js";
2
- export { migrateLegacyBoard } from "./upgrade-board.js";
3
2
  /** Plans an upgrade transaction from the installed manifest to the current package. */
4
3
  export declare const planUpgrade: (root: string, loop?: LoopConfig) => PlannedWrite[];
5
4
  export interface UpgradeResult {
@@ -1,95 +1,32 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { findAdapterForUpgrade, resolvedAdaptersForUpgrade } from "./adapters.js";
4
- import { installedHashFor, managedContentHash, planLoopSelection, readManifest } from "./installer.js";
3
+ import { findAdapter, resolvedAdapters } from "./adapters.js";
5
4
  import { legacyV021AdapterContents, legacyV021Hashes } from "./legacy.js";
6
5
  import { planProjectLayoutMigration } from "./project-migration.js";
7
- import { mergeManagedBlock } from "./managed-content.js";
6
+ import { legacyManifest, managedContentHash, planLoopSelection, planManagedWrites, preserveOwnedFile, readManifest, splitInventoryKey } from "./managed-planning.js";
8
7
  import { resolveProjectPath, resolveProjectPathWithoutSymlinks } from "./project-path.js";
9
8
  import { prepareV4Migration, validateState } from "./state.js";
10
- import { contentHash, coreWorkflowFiles, initialProjectFiles, manifestSpec } from "./workflow.js";
9
+ import { contentHash, coreWorkflowFiles, initialProjectFiles, isExecutableAsset, manifestSpec } from "./workflow.js";
11
10
  import { migrateLegacyBoard } from "./upgrade-board.js";
12
- export { migrateLegacyBoard } from "./upgrade-board.js";
13
11
  const safePath = (root, relative) => resolveProjectPath(root, relative, "Unsafe upgrade path");
14
12
  const assertNoSymlinkPath = (root, relative) => { resolveProjectPathWithoutSymlinks(root, relative, "Unsafe upgrade path", "Refusing to upgrade symlink path"); };
15
- const legacyManifest = (root) => {
16
- const path = join(resolve(root), ".agents/aidlc/manifest.json");
17
- if (!existsSync(path))
18
- return false;
19
- try {
20
- const parsed = JSON.parse(readFileSync(path, "utf8"));
21
- return parsed.managedBy === "aidlc-workflow" && (parsed.schemaVersion === 0 || parsed.schemaVersion === 1);
22
- }
23
- catch {
24
- return false;
25
- } /* intentional: a malformed/missing manifest is not a legacy v0.x install */
26
- };
27
13
  /* --- Managed file planning -------------------------------------------------- */
28
- const planManaged = (root, spec, installedHash, legacy) => {
29
- const target = safePath(root, spec.path);
30
- if (!existsSync(target))
31
- return { ...spec, action: "create", reason: "new managed file" };
32
- const current = readFileSync(target, "utf8");
33
- const next = spec.strategy === "managed-block" ? mergeManagedBlock(spec, current) : spec.content;
34
- const currentHash = managedContentHash(spec, current);
35
- const nextHash = contentHash(spec.strategy === "managed-block" ? spec.content.trimEnd() : spec.content);
36
- if (currentHash === nextHash)
37
- return { ...spec, content: next, action: next === current ? "skip" : "update", reason: "managed content already current" };
38
- if (installedHash && currentHash === installedHash)
39
- return { ...spec, content: next, action: "update", reason: "unchanged managed file" };
40
- const legacyAdapter = legacyV021AdapterContents[spec.path];
41
- const legacyAdapterHash = legacyAdapter ? contentHash(spec.strategy === "managed-block" ? legacyAdapter.trimEnd() : legacyAdapter) : undefined;
42
- if (legacy && (legacyV021Hashes[spec.path] === currentHash || legacyAdapterHash === currentHash))
43
- return { ...spec, content: next, action: "update", reason: "recognized v0.2.1 baseline" };
44
- if (spec.strategy === "managed-block" && current.includes(`<!-- aidlc-installer:${spec.owner} -->`))
45
- return { ...spec, content: next, action: "conflict", reason: "managed prompt block was modified" };
46
- return { ...spec, content: next, action: "conflict", reason: legacy ? "legacy core differs from known baseline" : "managed file differs from installed hash" };
47
- };
48
- /** Merges a later owner of a shared managed-block path into the already-emitted plan entry. */
49
- const mergeSharedBlockOwner = (project, previous, spec, installed) => {
50
- if (!["create", "update", "skip"].includes(previous.action))
51
- return;
52
- const target = safePath(project, spec.path);
53
- const current = existsSync(target) ? readFileSync(target, "utf8") : "";
54
- // Every owner block is checked before merging so a user edit to a later
55
- // adapter's block is reported instead of being silently overwritten.
56
- const installedBlockHash = installedHashFor(installed?.files, spec);
57
- const currentBlockHash = managedContentHash(spec, current);
58
- if (installedBlockHash !== undefined && currentBlockHash !== undefined && installedBlockHash !== currentBlockHash) {
59
- previous.action = "conflict";
60
- previous.reason = "managed prompt block was modified";
61
- return;
62
- }
63
- previous.content = mergeManagedBlock(spec, previous.content);
64
- previous.action = existsSync(target) ? (previous.content === current ? "skip" : "update") : "create";
65
- previous.reason = "managed prompt block";
66
- };
67
- /** Plans managed replacements and shared managed-block writes across every owner. */
68
- const planManagedFiles = (project, managed, installed, legacy) => {
69
- const plan = [];
70
- const mergedBlocks = new Map();
71
- for (const spec of managed) {
72
- const installedHash = installedHashFor(installed?.files, spec);
73
- if (spec.strategy !== "managed-block") {
74
- plan.push(planManaged(project, spec, installedHash, legacy));
75
- continue;
76
- }
77
- const previous = mergedBlocks.get(spec.path);
78
- if (!previous) {
79
- const write = planManaged(project, spec, installedHash, legacy);
80
- plan.push(write);
81
- mergedBlocks.set(spec.path, write);
82
- continue;
83
- }
84
- mergeSharedBlockOwner(project, previous, spec, installed);
14
+ /** Upgrade's planning policy: ownership is proven against installed hashes and the frozen v0.2.1 baselines. */
15
+ const upgradePlanPolicy = (installed, legacy) => ({
16
+ flow: "upgrade",
17
+ installed,
18
+ legacy,
19
+ resolvePath: safePath,
20
+ legacyBaseline: (spec, currentHash) => {
21
+ const legacyAdapter = legacyV021AdapterContents[spec.path];
22
+ const legacyAdapterHash = legacyAdapter ? contentHash(spec.strategy === "managed-block" ? legacyAdapter.trimEnd() : legacyAdapter) : undefined;
23
+ return legacyV021Hashes[spec.path] === currentHash || legacyAdapterHash === currentHash;
85
24
  }
86
- return plan;
87
- };
88
- /* --- Obsolete files and project-owned files ---------------------------------- */
25
+ });
89
26
  /** Deletes obsolete managed files tracked by the installed manifest that no longer ship in this release. */
90
27
  const planObsoleteRemovals = (project, plan, installed, managed) => {
91
28
  for (const [key, item] of Object.entries(installed.files)) {
92
- const path = key.includes("#") ? key.slice(0, key.lastIndexOf("#")) : key;
29
+ const path = splitInventoryKey(key).path;
93
30
  // Shared prompt blocks are upgraded through the merged plan above; never delete their whole path here.
94
31
  if (item.strategy === "managed-block")
95
32
  continue;
@@ -107,8 +44,7 @@ const preserveProjectFiles = (project, plan, legacy) => {
107
44
  for (const spec of initialProjectFiles()) {
108
45
  if (legacy && spec.path === ".agents/data/state/aidlc-state.json" && !existsSync(safePath(project, spec.path)))
109
46
  continue;
110
- const target = safePath(project, spec.path);
111
- plan.push(existsSync(target) ? { ...spec, content: readFileSync(target, "utf8"), action: "preserve", reason: `${spec.ownershipClass}-owned file` } : { ...spec, action: "create", reason: `new ${spec.ownershipClass}-owned file` });
47
+ plan.push(preserveOwnedFile(safePath(project, spec.path), spec));
112
48
  }
113
49
  };
114
50
  /* --- Canonical state and legacy BOARD migration -------------------------------- */
@@ -171,11 +107,11 @@ const removeLegacyBoard = (project, plan) => {
171
107
  /* --- planUpgrade orchestration ------------------------------------------------ */
172
108
  /** Selects which adapters to upgrade: manifest-registered ones, else auto-detected installable ones. */
173
109
  const selectedUpgradeAgents = (project, installed) => {
174
- const upgradeAdapters = resolvedAdaptersForUpgrade();
110
+ const upgradeAdapters = resolvedAdapters();
175
111
  const requested = installed?.adapters?.length ? installed.adapters : upgradeAdapters.filter((adapter) => adapter.detect(project)).map((adapter) => adapter.id);
176
- return requested.filter((id) => findAdapterForUpgrade(id));
112
+ return requested.filter((id) => findAdapter(id));
177
113
  };
178
- const agentUpgradeFiles = (selected) => selected.flatMap((id) => findAdapterForUpgrade(id)?.files() ?? []).map((file) => ({ ...file, ownershipClass: "managed" }));
114
+ const agentUpgradeFiles = (selected) => selected.flatMap((id) => findAdapter(id)?.files() ?? []).map((file) => ({ ...file, ownershipClass: "managed" }));
179
115
  /** Plans an upgrade transaction from the installed manifest to the current package. */
180
116
  export const planUpgrade = (root, loop) => {
181
117
  const project = resolve(root);
@@ -185,7 +121,7 @@ export const planUpgrade = (root, loop) => {
185
121
  throw new Error("No owned AI-DLC installation found. Use init instead of upgrade.");
186
122
  const selected = selectedUpgradeAgents(project, installed);
187
123
  const managed = [...coreWorkflowFiles(), ...agentUpgradeFiles(selected)];
188
- const plan = planManagedFiles(project, managed, installed, legacy);
124
+ const plan = planManagedWrites(project, managed, upgradePlanPolicy(installed, legacy));
189
125
  if (installed)
190
126
  planObsoleteRemovals(project, plan, installed, managed);
191
127
  preserveProjectFiles(project, plan, legacy);
@@ -215,6 +151,9 @@ const stageUpgradedWrites = (project, writes, backup, stage, journal) => {
215
151
  const staged = join(stage, item.path);
216
152
  mkdirSync(dirname(staged), { recursive: true });
217
153
  writeFileSync(staged, item.contentEncoding === "base64" ? Buffer.from(item.content, "base64") : item.content);
154
+ // The commit step renames the staged file, so its mode carries over to the target.
155
+ if (isExecutableAsset(item.path) && process.platform !== "win32")
156
+ chmodSync(staged, 0o755);
218
157
  if (item.path.endsWith(".json") && item.contentEncoding !== "base64")
219
158
  JSON.parse(item.content);
220
159
  }
@@ -1,6 +1,10 @@
1
1
  import type { AgentId, FileSpec, WorkflowManifest } from "./model.js";
2
2
  export declare const contentHash: (content: string | Uint8Array) => string;
3
3
  export declare const coreWorkflowFiles: () => FileSpec[];
4
+ /** Commented template for the loop daemon's env file; seeded once and never overwritten. */
5
+ export declare const LOOP_ENV_TEMPLATE = "# AI-DLC loop environment, read by `loop.sh up` / `aidlc-kit loop start` (local mode).\n# Precedence: shell exports > this file > values detected from your tools' own config.\n# Keep real keys here only; .agents/.gitignore excludes this file from git.\n# See what the loop will use with: .agents/aidlc/scripts/loop.sh check\n#\n# --- OpenCode (lead or workers set to \"opencode\") ---\n# Model as provider/model[#variant] (list them with: opencode models).\n# When unset, \"model\" from opencode.json is used.\n# AIDLC_OPENCODE_MODEL=opencode-go/<model>\n# Worker key; when unset it is read from ~/.local/share/opencode/auth.json.\n# The key follows the model prefix: openai/ -> OPENAI_API_KEY, anthropic/ -> ANTHROPIC_API_KEY, otherwise OPENCODE_API_KEY.\n# OPENCODE_API_KEY=\n#\n# --- Codex workers ---\n# When unset, read from ~/.codex/auth.json (after: codex login --with-api-key).\n# OPENAI_API_KEY=\n#\n# --- Claude Code workers ---\n# When unset, read from \"env\" in .claude/settings.local.json, .claude/settings.json, or ~/.claude/settings.json.\n# ANTHROPIC_API_KEY=\n#\n# --- Optional ---\n# Override the auto-built worker image:\n# AIDLC_WORKER_DOCKER_IMAGE=\n# Where worker worktrees live (default: the system temp directory):\n# AIDLC_LOCAL_WORKTREES_ROOT=\n";
6
+ /** Installed files that must be executable (POSIX launchers). */
7
+ export declare const isExecutableAsset: (path: string) => boolean;
4
8
  export declare const initialProjectFiles: () => FileSpec[];
5
9
  export declare const manifestKey: (spec: Pick<FileSpec, "path" | "owner" | "strategy">) => string;
6
10
  export declare const manifestFor: (files: FileSpec[], adapters: AgentId[]) => WorkflowManifest;