@felixdotgo/aidlc-kit 0.2.0 → 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 (109) hide show
  1. package/DEVELOPMENT.md +13 -26
  2. package/README.md +35 -39
  3. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  4. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  5. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  6. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  7. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
  8. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
  9. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
  32. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  33. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  36. package/dist/assets/ui/changes.js +83 -0
  37. package/dist/assets/ui/common.js +664 -0
  38. package/dist/assets/ui/dashboard.css +281 -59
  39. package/dist/assets/ui/dashboard.js +77 -0
  40. package/dist/assets/ui/detail.js +159 -0
  41. package/dist/assets/ui/live-log.js +332 -0
  42. package/dist/services/aidlc-coordination/package.json +1 -1
  43. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
  44. package/dist/src/cli/argv.js +10 -4
  45. package/dist/src/cli/commands.js +52 -22
  46. package/dist/src/cli/loop-launcher.d.ts +47 -0
  47. package/dist/src/cli/loop-launcher.js +322 -0
  48. package/dist/src/cli.js +3 -0
  49. package/dist/src/installer.js +4 -2
  50. package/dist/src/model/records.d.ts +4 -0
  51. package/dist/src/state/validate.js +5 -0
  52. package/dist/src/task-ui.d.ts +31 -52
  53. package/dist/src/task-ui.js +25 -1562
  54. package/dist/src/ui/assets.d.ts +14 -0
  55. package/dist/src/ui/assets.js +45 -0
  56. package/dist/src/ui/changes.d.ts +83 -0
  57. package/dist/src/ui/changes.js +498 -0
  58. package/dist/src/ui/containers.d.ts +71 -0
  59. package/dist/src/ui/containers.js +214 -0
  60. package/dist/src/ui/daemon.d.ts +75 -0
  61. package/dist/src/ui/daemon.js +232 -0
  62. package/dist/src/ui/data.d.ts +42 -0
  63. package/dist/src/ui/data.js +89 -0
  64. package/dist/src/ui/docs.d.ts +13 -0
  65. package/dist/src/ui/docs.js +39 -0
  66. package/dist/src/ui/files.d.ts +3 -0
  67. package/dist/src/ui/files.js +20 -0
  68. package/dist/src/ui/http.d.ts +14 -0
  69. package/dist/src/ui/http.js +80 -0
  70. package/dist/src/ui/live.d.ts +5 -0
  71. package/dist/src/ui/live.js +43 -0
  72. package/dist/src/ui/markdown.d.ts +2 -0
  73. package/dist/src/ui/markdown.js +127 -0
  74. package/dist/src/ui/options.d.ts +48 -0
  75. package/dist/src/ui/options.js +81 -0
  76. package/dist/src/ui/security.d.ts +34 -0
  77. package/dist/src/ui/security.js +38 -0
  78. package/dist/src/ui/server.d.ts +39 -0
  79. package/dist/src/ui/server.js +347 -0
  80. package/dist/src/ui/sse.d.ts +14 -0
  81. package/dist/src/ui/sse.js +281 -0
  82. package/dist/src/ui/views/board.d.ts +7 -0
  83. package/dist/src/ui/views/board.js +131 -0
  84. package/dist/src/ui/views/changes.d.ts +19 -0
  85. package/dist/src/ui/views/changes.js +154 -0
  86. package/dist/src/ui/views/components.d.ts +56 -0
  87. package/dist/src/ui/views/components.js +113 -0
  88. package/dist/src/ui/views/docs.d.ts +15 -0
  89. package/dist/src/ui/views/docs.js +64 -0
  90. package/dist/src/ui/views/layout.d.ts +44 -0
  91. package/dist/src/ui/views/layout.js +233 -0
  92. package/dist/src/ui/views/task.d.ts +25 -0
  93. package/dist/src/ui/views/task.js +320 -0
  94. package/dist/src/upgrade.js +5 -2
  95. package/dist/src/workflow.d.ts +4 -0
  96. package/dist/src/workflow.js +33 -1
  97. package/docs/README.md +48 -33
  98. package/docs/architecture.md +101 -38
  99. package/docs/command-reference.md +269 -93
  100. package/docs/configuration.md +62 -42
  101. package/docs/coordination-service.md +85 -36
  102. package/docs/design-decisions.md +35 -31
  103. package/docs/development.md +111 -61
  104. package/docs/loop-local-run.md +135 -402
  105. package/docs/loop-runbook.md +84 -105
  106. package/docs/operating-workflow.md +55 -42
  107. package/docs/testing-and-release.md +70 -51
  108. package/docs/troubleshooting.md +165 -69
  109. package/package.json +1 -1
package/DEVELOPMENT.md CHANGED
@@ -1,45 +1,32 @@
1
1
  # Contributing to aidlc-kit
2
2
 
3
- This file is the short entry point for maintainers and contributors. Detailed guidance lives in [`docs/`](./docs/README.md) so that user setup, system operation, and repository development can evolve independently.
3
+ Entry point for contributors. Details live in [`docs/`](./docs/README.md).
4
4
 
5
5
  ## Local bootstrap
6
6
 
7
- Requirements: Node.js 20 or newer and npm.
7
+ Node.js 20.17+ and npm.
8
8
 
9
9
  ```sh
10
10
  npm install
11
- npm test
12
- npm run lint
11
+ npm test # build + unit tests
12
+ npm run lint # tsc --noEmit
13
13
  ```
14
14
 
15
- ## Workflow bootstrap
16
-
17
- `.agents/` is gitignored; every contributor installs the repo's own workflow locally.
18
-
19
- ```sh
20
- npm install
21
- npm run build
22
- node dist/src/cli.js init . --agent <codex|claude|opencode> --yes
23
- ```
24
-
25
- Then configure `.agents/config.json`: set `commands` to build/test/lint as executable+args arrays (`build`: `npm`/`["run","build"]`, `test`: `npm`/`["test"]`, `lint`: `npm`/`["run","lint"]`) and `agentState` mapping your adapter id to `"scripted"`. Validate with `node dist/src/cli.js profile validate .`.
26
-
27
- See [Configuration reference](./docs/configuration.md) and [Development guide](./docs/development.md).
15
+ To develop with the repository's own workflow (`.agents/` is gitignored), follow [Dogfooding this repository](./docs/development.md#dogfooding-this-repository).
28
16
 
29
17
  ## Contributor documentation
30
18
 
31
- - [Development guide](./docs/development.md) — dependencies, repository layout, implementation boundaries, and local commands.
32
- - [Testing and release](./docs/testing-and-release.md) — focused tests, package smoke test, evaluator, and release readiness.
33
- - [Architecture](./docs/architecture.md) — module ownership, data flow, and installed-runtime parity.
34
- - [Configuration reference](./docs/configuration.md) — profiles, rules, provider permissions, and project-owned configuration.
19
+ - [Development guide](./docs/development.md) — layout, local commands, boundaries, code style, dogfooding.
20
+ - [Testing and release](./docs/testing-and-release.md) — focused tests, Docker/PostgreSQL verification, package smoke test, release checklist.
21
+ - [Architecture](./docs/architecture.md) — module ownership and source → bundle → installed flow.
22
+ - [Design decisions](./docs/design-decisions.md) — numbered decisions and how to add one.
35
23
 
36
24
  ## Documentation policy
37
25
 
38
- - Keep `README.md` focused on package requirements and setup.
39
- - Add operational and system guidance to the relevant page in `docs/`.
40
- - Update command/configuration examples and their tests in the same change as a public contract.
41
- - Do not duplicate canonical agent instructions from `assets/aidlc/` in overview documentation.
26
+ - `README.md` is the fastest path to install and use; everything else goes in `docs/` per [Adding or changing a page](./docs/README.md#adding-or-changing-a-page).
27
+ - A public contract change (command, config field, schema, installed asset) updates its guide and test in the same change.
28
+ - Never duplicate canonical agent instructions from `assets/aidlc/`; link to them.
42
29
 
43
30
  ## Delivery boundary
44
31
 
45
- Running verification does not authorise publishing, tagging, committing, or pushing. Those actions require separate human approval.
32
+ Passing verification does not authorise publishing, tagging, committing, or pushing; each needs separate human approval.
package/README.md CHANGED
@@ -1,67 +1,63 @@
1
1
  # aidlc-kit
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@felixdotgo/aidlc-kit)](https://www.npmjs.com/package/@felixdotgo/aidlc-kit)
3
+ [![version](https://img.shields.io/badge/version-0.3.0-blue)](https://www.npmjs.com/package/@felixdotgo/aidlc-kit)
4
4
  [![license](https://img.shields.io/npm/l/@felixdotgo/aidlc-kit)](https://github.com/felixdotgo/aidlc-kit/blob/main/LICENSE)
5
5
  [![node](https://img.shields.io/node/v/@felixdotgo/aidlc-kit)](https://nodejs.org/)
6
6
 
7
- `@felixdotgo/aidlc-kit` installs an AI-DLC workflow into an existing project. The workflow gives coding agents a shared task lifecycle, dependency-aware work, explicit human review gates, and project-owned state and rules. The normal lead runs in your Codex, Claude Code, or OpenCode conversation; an optional coordination service supports workers on multiple machines.
8
-
9
- Workflow assets are bundled with the package, `remoteUpdates` is `false`, and normal status checks never contact a registry. Fleet execution is opt-in and uses a separately deployed service. The optional end-to-end loop is also opt-in: select a mode during `init` or a human-run `upgrade`, then start it explicitly with `aidlc-kit loop start` — selecting a mode never starts or approves anything by itself.
7
+ Installs an AI-DLC workflow into an existing project: a shared task lifecycle, explicit human review gates, and project-owned state for Codex, Claude Code, and OpenCode. Optionally, a loop daemon runs tasks end-to-end on one machine or across a fleet.
10
8
 
11
9
  > [!WARNING]
12
- > **Local loop mode is usable** today; **fleet mode and the AI-DLC Coordination service are still under active development**. The loop is **experimental** — the package is pre-1.0 (`0.x`), so CLI flags, `loop.*`/`mcp.*` configuration fields, environment variables, and installed layouts can change incompatibly in any release before `1.0.0`.
13
-
14
- ## Requirements
15
-
16
- - Node.js 20.17 or newer.
17
- - An existing project directory.
18
- - Codex, Claude Code, or OpenCode.
19
- - Supported platforms: Linux and macOS are fully supported; Windows is best-effort (see `docs/development.md` Platform support). Loop/fleet features require Docker and a POSIX shell.
10
+ > Pre-1.0 (`0.x`). The local loop is usable; fleet mode and the coordination service are still in development. See [Status and stability](./docs/loop-runbook.md#status-and-stability).
20
11
 
21
12
  ## Quick start
22
13
 
23
- Run these commands from your existing project's directory to preview the installation, then apply it:
14
+ Requires Node.js 20.17+ and Codex, Claude Code, or OpenCode (Linux/macOS; Windows is best-effort).
24
15
 
25
16
  ```sh
26
- npx --package @felixdotgo/aidlc-kit@0.2.0 aidlc-kit init . --agent codex --dry-run
27
- npx --package @felixdotgo/aidlc-kit@0.2.0 aidlc-kit init . --agent codex --yes
17
+ npx --package @felixdotgo/aidlc-kit@0.3.0 aidlc-kit init . # previews, then installs after you confirm
18
+ npx --package @felixdotgo/aidlc-kit@0.3.0 aidlc-kit doctor . --strict
28
19
  ```
29
20
 
30
- `npx` may ask permission to download the package on first use. These examples pin version `0.2.0`. After installation, the project's lifecycle scripts run locally without `npx` or a registry connection.
21
+ In a terminal, `init` asks for the adapter (unless exactly one is detected or you pass `--agent codex|claude|opencode` / `--all`) and the loop mode (unless `--loop-mode …`), previews the files, then asks you to confirm; `--yes` skips the confirmation, `--dry-run` writes nothing. The installed lifecycle scripts run locally and never contact a registry.
31
22
 
32
- Use the adapter that matches the coding tool:
23
+ Then ask your agent for a change:
33
24
 
34
- | Tool | `--agent` value | Installed entry point |
35
- | --- | --- | --- |
36
- | Codex | `codex` | `AGENTS.md` |
37
- | Claude Code | `claude` | `CLAUDE.md` and phase skills |
38
- | OpenCode | `opencode` | `AGENTS.md` |
25
+ ```text
26
+ Add rate limiting to the login endpoint using the AI-DLC workflow.
27
+ ```
39
28
 
40
- Use `--all` to install every supported adapter. Codex installs workspace-write sandboxing with on-request approvals and a project execpolicy that permits only AI-DLC lifecycle scripts; trust the project before starting Codex so that it loads the project configuration. Claude Code installs a local permission allowlist for the same lifecycle-script command prefix without granting general Bash access. OpenCode installs an owner-scoped block in `AGENTS.md` and uses `.opencode/` only for detection; it writes no OpenCode configuration, MCP, or permission file, so your own settings are not overridden. When Codex and OpenCode are installed together, their blocks merge in the same `AGENTS.md`. Use `--force --yes` only for an initial installation when replacing an unmanaged conflicting file is intentional.
29
+ The agent creates a task and stops at three review steps — Confirm requirements, Approve plan, Review implementation — each needing your explicit reply. The Codex and Claude Code adapters pre-approve only the lifecycle scripts, and OpenCode gets instructions only ([Agent permissions](./docs/configuration.md#agent-permissions)); for Codex, trust the project first.
41
30
 
42
- Confirm the installed project locally:
31
+ ## Run the loop (optional)
43
32
 
44
- ```sh
45
- npx --package @felixdotgo/aidlc-kit@0.2.0 aidlc-kit status .
46
- npx --package @felixdotgo/aidlc-kit@0.2.0 aidlc-kit doctor . --strict
47
- ```
33
+ A daemon drives tasks end-to-end on a Linux host with Docker, stopping at every review step for you:
48
34
 
49
- ## Documentation
35
+ 1. In `.agents/config.json` update the `loop` section to match your environment, e.g.,
36
+ ```json
37
+ ...
38
+ "loop": {
39
+ "mode": "local",
40
+ "agents": {
41
+ "lead": "opencode",
42
+ "workers": {
43
+ "worker-a": "opencode"
44
+ }
45
+ }
46
+ }
47
+ ...
48
+ ```
49
+ 2. Put credentials in the git-ignored `.agents/loop.env`; keys your tools already store are detected.
50
+ 3. `./.agents/aidlc/scripts/loop.sh up` checks the environment, starts the daemon, and serves the dashboard (`--open` launches the browser); `loop.sh down` stops both.
50
51
 
51
- The detailed documentation is organised by task and audience:
52
+ Setup: [Loop setup runbook](./docs/loop-runbook.md). Daily use: [Local loop walkthrough](./docs/loop-local-run.md).
53
+
54
+ ## Documentation
52
55
 
53
- - [Documentation index](./docs/README.md) — choose the right guide.
54
- - [Operating the workflow](./docs/operating-workflow.md) — lifecycle, state, gates, and blocked work.
55
- - [Configuration](./docs/configuration.md) — project configuration, profiles, rules, and adapters.
56
- - [Command reference](./docs/command-reference.md) — every package CLI command and safety constraint.
57
- - [Development](./docs/development.md) — local contributor setup, source layout, and implementation rules.
58
- - [Testing and release](./docs/testing-and-release.md) — verification, evaluator, package smoke test, and release readiness.
59
- - [Architecture](./docs/architecture.md) — package boundaries and the source-to-installed flow.
60
- - [AI-DLC Coordination service](./docs/coordination-service.md) — opt-in Docker/remote coordination, security, and polling contract.
56
+ [Index](./docs/README.md) · [Operating the workflow](./docs/operating-workflow.md) · [Configuration](./docs/configuration.md) · [Command reference](./docs/command-reference.md) · [Troubleshooting](./docs/troubleshooting.md) · [Coordination service (fleet)](./docs/coordination-service.md) · [Contributing](./DEVELOPMENT.md)
61
57
 
62
58
  ## Safety note
63
59
 
64
- Only a human may initiate or apply a workflow upgrade. `upgrade` rejects `--yes` and `--force`, requires an interactive terminal, and asks the human to type the target package version. See the [command reference](./docs/command-reference.md#upgrade) before changing an existing installation.
60
+ Only a human may upgrade: `upgrade` rejects `--yes` and `--force`, needs an interactive terminal, and asks you to type the target version ([details](./docs/command-reference.md#upgrade)).
65
61
 
66
62
  ## License
67
63
 
@@ -20,7 +20,7 @@ This file is the compact kernel contract. Project customization lives under `.ag
20
20
 
21
21
  Read-only questions remain off-workflow and do not create state. The current Codex, Claude Code, or OpenCode conversation is the interactive lead for local mode. It keeps following fresh `nextAction` in the same conversation, uses native host delegation for independent items, and presents gates here. A later explicit user reply approves a gate. No nested Codex/Claude/OpenCode CLI process is needed for interactive mode.
22
22
 
23
- On `resume <task-id>` in a reopened conversation, first read `task next` and `task show`, then run `assignment.mjs reconcile <task-id>` and inspect each `reported` or `uncertain` assignment against canonical item status, owned files, and worker output. Resume only work still required by canonical state. A stale assignment never authorizes replaying a lifecycle mutation or gate approval. Local assignment records under `.agents/data/loop/assignments/` are scheduling metadata, not lifecycle authority. Fleet mode uses an explicitly selected remote authority and fenced service claims; never silently switch between local and remote state.
23
+ On `resume <task-id>` in a reopened conversation, first read `task next` and `task show`, then run `assignment.mjs reconcile <task-id>` and inspect each `reported` or `uncertain` assignment against canonical item status, owned files, and worker output. Resume only work still required by canonical state. A stale assignment never authorizes replaying a lifecycle mutation or gate approval. Local assignment records under `.agents/data/tasks/<task-id>/runtime/assignments.json` are scheduling metadata, not lifecycle authority. Fleet mode uses an explicitly selected remote authority and fenced service claims; never silently switch between local and remote state.
24
24
 
25
25
  ## Review steps
26
26
 
@@ -35,6 +35,8 @@
35
35
  "legacyG2Wait": { "$ref": "#/$defs/legacyG2Wait" },
36
36
  "predecessorTaskId": { "type": "string", "minLength": 1 },
37
37
  "successorTaskId": { "type": "string", "minLength": 1 },
38
+ "baseCommit": { "type": "string", "pattern": "^[0-9a-f]{7,64}$" },
39
+ "finalCommit": { "type": "string", "pattern": "^[0-9a-f]{7,64}$" },
38
40
  "createdAt": { "type": "string" },
39
41
  "updatedAt": { "type": "string" }
40
42
  },
@@ -1,27 +1,31 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
- import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { acquireHolder, describeHolder } from "./loop-holder.mjs";
6
+ import { resolveTaskRuntimeRead, taskRuntimePath } from "./task-runtime.mjs";
7
+ import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
5
8
 
6
9
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
7
10
  const states = new Set(["leased", "reported", "uncertain", "integrated", "released"]);
8
11
  const safeId = (value, label) => { if (typeof value !== "string" || !idPattern.test(value)) throw new Error(`Invalid ${label}`); return value; };
9
12
  const iso = () => new Date().toISOString();
10
- const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
13
 
14
+ /** Write location of the task's assignment records
15
+ * (`.agents/data/tasks/<task>/runtime/assignments.json`; `.lock` for the mutex),
16
+ * refusing symlinked path components. */
12
17
  export function assignmentPath(root, taskId, suffix = ".json") {
13
18
  safeId(taskId, "task id");
14
- let path = resolve(root);
15
- for (const part of [".agents", "data", "loop", "assignments", `${taskId}${suffix}`]) {
16
- path = join(path, part);
17
- try { if (lstatSync(path).isSymbolicLink()) throw new Error(`Assignment path crosses symlink: ${path}`); }
18
- catch (error) { if (error?.code !== "ENOENT") throw error; }
19
- }
20
- return path;
19
+ if (![".json", ".lock"].includes(suffix)) throw new Error(`Unknown assignment file suffix: ${suffix}`);
20
+ return taskRuntimePath(root, taskId, suffix === ".lock" ? "assignments.lock" : "assignments.json");
21
21
  }
22
22
 
23
+ /** Read location: the runtime file, else the pre-runtime
24
+ * `.agents/data/loop/assignments/<task>.json` until it has been migrated. */
25
+ export const assignmentReadPath = (root, taskId) => resolveTaskRuntimeRead(root, safeId(taskId, "task id"), "assignments.json");
26
+
23
27
  function read(root, taskId) {
24
- const path = assignmentPath(root, taskId);
28
+ const path = assignmentReadPath(root, taskId);
25
29
  let value;
26
30
  try { value = JSON.parse(readFileSync(path, "utf8")); }
27
31
  catch (error) {
@@ -56,44 +60,66 @@ function write(root, taskId, value) {
56
60
  }
57
61
  }
58
62
 
59
- function alive(pid) {
60
- try { process.kill(pid, 0); return true; }
61
- catch (error) { return error?.code !== "ESRCH"; }
62
- }
63
-
63
+ /** Short mutex around one read-modify-write of `assignments/<task>.json`
64
+ * (kind lock, 30s TTL, no heartbeat). Waits up to 5s for another holder. */
64
65
  function locked(root, taskId, action) {
66
+ // A legacy record moves under runtime/ before the first write, so the
67
+ // writer never has to choose between two locations.
68
+ migrateTaskRuntime(root, taskId, { names: ["assignments.json"] });
65
69
  const path = assignmentPath(root, taskId, ".lock");
66
- mkdirSync(dirname(path), { recursive: true });
67
- const token = randomUUID();
68
- const deadline = Date.now() + 5000;
69
- for (;;) {
70
- try {
71
- const fd = openSync(path, "wx", 0o600);
72
- try { writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); fsyncSync(fd); }
73
- finally { closeSync(fd); }
74
- break;
75
- } catch (error) {
76
- if (error?.code !== "EEXIST") throw error;
77
- let owner;
78
- try { owner = JSON.parse(readFileSync(path, "utf8")); }
79
- catch { throw new Error(`Corrupt assignment lock for ${taskId}; inspect before retry`); }
80
- if (!Number.isSafeInteger(owner.pid) || typeof owner.token !== "string") throw new Error(`Corrupt assignment lock for ${taskId}`);
81
- if (!alive(owner.pid)) {
82
- const current = JSON.parse(readFileSync(path, "utf8"));
83
- if (current.token === owner.token) unlinkSync(path);
84
- continue;
85
- }
86
- if (Date.now() >= deadline) throw new Error(`Timed out waiting for assignment lock: ${taskId}`);
87
- wait(10);
88
- }
89
- }
70
+ const handle = acquireHolder({
71
+ path, kind: "lock", scope: `assignments:${taskId}`, purpose: "assignment read-modify-write", waitMs: 5000,
72
+ busyMessage: (entry) => `Timed out waiting for assignment lock: ${taskId}: ${describeHolder(entry)}`,
73
+ });
90
74
  try { return action(); }
91
75
  finally {
92
- const current = JSON.parse(readFileSync(path, "utf8"));
93
- if (current.token === token) unlinkSync(path);
76
+ // A release failure must not mask the committed write; the lock then
77
+ // expires on its own after its 30s TTL.
78
+ try { handle.release(); } catch { /* expires via TTL */ }
94
79
  }
95
80
  }
96
81
 
82
+ /** User-facing labels for the persisted assignment (claim) states. */
83
+ export const ASSIGNMENT_STATE_LABELS = Object.freeze({
84
+ leased: "claim held",
85
+ expired: "claim expired; needs reconcile",
86
+ uncertain: "expired or outcome unknown; needs reconcile",
87
+ reported: "worker reported; awaiting integration",
88
+ integrated: "integrated",
89
+ released: "released",
90
+ });
91
+
92
+ /** Display state computed at read time: a `leased` claim past its
93
+ * leaseExpiresAt shows as `expired` (mutations persist it as `uncertain`). */
94
+ export function effectiveAssignmentState(assignment, now = Date.now()) {
95
+ return assignment.state === "leased" && Date.parse(assignment.leaseExpiresAt) <= now ? "expired" : assignment.state;
96
+ }
97
+
98
+ const reconcileCommand = (taskId) => `node .agents/aidlc/scripts/assignment.mjs reconcile ${taskId}`;
99
+ const releaseCommand = (taskId, id) => `node .agents/aidlc/scripts/assignment.mjs release ${taskId} ${id}`;
100
+
101
+ /** One-line description of a claim: id, item, worker, paths, state, expiry. */
102
+ export function describeAssignment(assignment, now = Date.now()) {
103
+ const state = effectiveAssignmentState(assignment, now);
104
+ return `assignment ${assignment.id} (item ${assignment.itemId}, worker ${assignment.workerId}, paths ${(assignment.ownedPaths ?? []).join(", ")}, state ${state}: ${ASSIGNMENT_STATE_LABELS[state] ?? state}, claim expires ${assignment.leaseExpiresAt})`;
105
+ }
106
+
107
+ /** Remedy commands for a blocking claim. */
108
+ export function assignmentRemedy(taskId, assignment, now = Date.now()) {
109
+ const release = `${releaseCommand(taskId, assignment.id)} to drop the claim (discards the worker's unintegrated result)`;
110
+ const state = effectiveAssignmentState(assignment, now);
111
+ if (state === "leased") return `wait for the worker to report or for the claim to expire; if the worker is gone, run ${release}`;
112
+ if (state === "expired" || state === "uncertain") return `the claim expired or its outcome is unknown: run ${reconcileCommand(taskId)} (marks it integrated if the item is done); if the item is not done and the worker is gone, run ${release}`;
113
+ return `run ${reconcileCommand(taskId)} once the item is done in canonical state; or run ${release}`;
114
+ }
115
+
116
+ const blockedBy = (taskId, assignment) => `${describeAssignment(assignment)}; ${assignmentRemedy(taskId, assignment)}`;
117
+
118
+ function notLeased(taskId, assignmentId, item, suffix = "") {
119
+ if (!item) return new Error(`Assignment is not leased${suffix}: assignment ${assignmentId} does not exist for ${taskId}; inspect: node .agents/aidlc/scripts/assignment.mjs show ${taskId}`);
120
+ return new Error(`Assignment is not leased${suffix}: ${describeAssignment(item)}; run ${reconcileCommand(taskId)}`);
121
+ }
122
+
97
123
  function canonical(root, taskId) {
98
124
  const raw = execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/state.mjs"), "task", "show", taskId, "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 });
99
125
  const result = JSON.parse(raw);
@@ -108,8 +134,12 @@ function expire(value, now) {
108
134
  }
109
135
  }
110
136
 
137
+ /** Read-only view: each assignment gains `effectiveState` and `stateLabel`
138
+ * (an overdue `leased` claim shows as `expired`); nothing is written. */
111
139
  export function showLocalAssignments(root, taskId) {
112
- return read(root, safeId(taskId, "task id"));
140
+ const value = read(root, safeId(taskId, "task id"));
141
+ const now = Date.now();
142
+ return { ...value, assignments: value.assignments.map((assignment) => { const effectiveState = effectiveAssignmentState(assignment, now); return { ...assignment, effectiveState, stateLabel: ASSIGNMENT_STATE_LABELS[effectiveState] ?? effectiveState }; }) };
113
143
  }
114
144
 
115
145
  export function claimLocalAssignment(root, { taskId, itemId, workerId, ownedPaths, leaseMs = 300000, baseCommit = null, branch = null, sessionId = null }) {
@@ -124,9 +154,11 @@ export function claimLocalAssignment(root, { taskId, itemId, workerId, ownedPath
124
154
  return locked(root, taskId, () => {
125
155
  const value = read(root, taskId); expire(value, Date.now());
126
156
  const active = value.assignments.filter((entry) => ["leased", "reported", "uncertain"].includes(entry.state));
127
- if (active.some((entry) => entry.itemId === itemId && entry.state === "leased")) throw new Error(`Item already leased: ${itemId}`);
128
- if (value.assignments.some((entry) => entry.itemId === itemId && ["reported", "uncertain"].includes(entry.state))) throw new Error(`Item has unresolved assignment; reconcile before retry: ${itemId}`);
129
- for (const other of active) for (const path of ownedPaths) for (const prior of other.ownedPaths) if (path === prior || path.startsWith(`${prior}/`) || prior.startsWith(`${path}/`)) throw new Error(`Owned path overlaps active assignment: ${path}`);
157
+ const leased = active.find((entry) => entry.itemId === itemId && entry.state === "leased");
158
+ if (leased) throw new Error(`Item already leased: ${itemId}: claimed by ${blockedBy(taskId, leased)}`);
159
+ const unresolved = value.assignments.find((entry) => entry.itemId === itemId && ["reported", "uncertain"].includes(entry.state));
160
+ if (unresolved) throw new Error(`Item has unresolved assignment; reconcile before retry: ${itemId}: ${blockedBy(taskId, unresolved)}`);
161
+ for (const other of active) for (const path of ownedPaths) for (const prior of other.ownedPaths) if (path === prior || path.startsWith(`${prior}/`) || prior.startsWith(`${path}/`)) throw new Error(`Owned path overlaps active assignment: ${path}: held by ${blockedBy(taskId, other)}`);
130
162
  const attempt = 1 + Math.max(0, ...value.assignments.filter((entry) => entry.itemId === itemId).map((entry) => entry.attempt));
131
163
  const assignment = { id: randomUUID(), taskId, itemId, workerId, ownedPaths, attempt, baseCommit, branch, sessionId, checkpoint: null, state: "leased", leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString(), idempotencyKey: randomUUID(), createdAt: iso(), updatedAt: iso() };
132
164
  value.assignments.push(assignment); write(root, taskId, value);
@@ -139,7 +171,7 @@ export function heartbeatLocalAssignment(root, taskId, assignmentId, leaseMs = 3
139
171
  return locked(root, taskId, () => {
140
172
  const value = read(root, taskId); expire(value, Date.now());
141
173
  const item = value.assignments.find((entry) => entry.id === assignmentId);
142
- if (!item || item.state !== "leased") throw new Error("Assignment is not leased");
174
+ if (!item || item.state !== "leased") throw notLeased(taskId, assignmentId, item);
143
175
  if (sessionId !== undefined) { if (typeof sessionId !== "string" || !sessionId || sessionId.length > 300) throw new Error("Invalid sessionId"); item.sessionId = sessionId; }
144
176
  item.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString(); item.updatedAt = iso(); write(root, taskId, value); return item;
145
177
  });
@@ -152,12 +184,26 @@ export function reportLocalAssignment(root, taskId, assignmentId, { checkpoint =
152
184
  return locked(root, taskId, () => {
153
185
  const value = read(root, taskId); expire(value, Date.now());
154
186
  const item = value.assignments.find((entry) => entry.id === assignmentId);
155
- if (!item || item.state !== "leased") throw new Error("Assignment is not leased; reconcile before reporting");
187
+ if (!item || item.state !== "leased") throw notLeased(taskId, assignmentId, item, "; reconcile before reporting");
156
188
  for (const path of changedPaths) if (!item.ownedPaths.some((owned) => path === owned || path.startsWith(`${owned}/`))) throw new Error(`Changed path is outside assignment scope: ${path}`);
157
189
  item.state = "reported"; item.checkpoint = checkpoint; item.commit = commit; item.changedPaths = changedPaths; item.updatedAt = iso(); write(root, taskId, value); return item;
158
190
  });
159
191
  }
160
192
 
193
+ /** Record the integration-branch commit created for a reported assignment, so
194
+ * the dashboard can show exactly what the item changed. It never changes the
195
+ * assignment state; `reconcile` still marks the item integrated. */
196
+ export function recordLocalIntegration(root, taskId, assignmentId, integrationCommit) {
197
+ safeId(taskId, "task id");
198
+ if (typeof integrationCommit !== "string" || !/^[a-f0-9]{40,64}$/.test(integrationCommit)) throw new Error("Invalid integration commit");
199
+ return locked(root, taskId, () => {
200
+ const value = read(root, taskId);
201
+ const item = value.assignments.find((entry) => entry.id === assignmentId);
202
+ if (!item || !["reported", "integrated"].includes(item.state)) throw new Error("Only a reported or integrated assignment can record its integration commit");
203
+ item.integrationCommit = integrationCommit; item.updatedAt = iso(); write(root, taskId, value); return item;
204
+ });
205
+ }
206
+
161
207
  export function reconcileLocalAssignments(root, taskId) {
162
208
  const task = canonical(root, taskId);
163
209
  return locked(root, taskId, () => {
@@ -5,6 +5,7 @@
5
5
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
7
7
  import { loadMemoryRegistry, nextAction, selectAgenticMemory } from "./store.mjs";
8
+ import { resolveTaskRuntimeRead } from "./task-runtime.mjs";
8
9
 
9
10
  const separator = process.platform === "win32" ? "\\" : "/";
10
11
 
@@ -228,10 +229,10 @@ export const compileContext = (root, task, phase = task.phase, options = {}) =>
228
229
  const phaseContract = readFileSync(phasePath, "utf8");
229
230
  let intake = "";
230
231
  if (phase === "clarify") {
231
- const parts = [".agents", "data", "tasks", task.id, "requirement.md"];
232
- let cursor = resolve(root), safe = true;
233
- for (const part of parts) { cursor = join(cursor, part); if (!existsSync(cursor)) { safe = false; break; } if (lstatSync(cursor).isSymbolicLink()) throw new Error("Unsafe requirement path"); }
234
- if (safe) { const requirement = readFileSync(cursor, "utf8"); if (Buffer.byteLength(requirement) > 8192) throw new Error("Requirement exceeds intake limit"); intake = `## User requirement\n${requirement}`; }
232
+ // The intake requirement (runtime/requirement.md, or the pre-runtime task-root copy); symlinked components are refused by the resolver.
233
+ let cursor;
234
+ try { cursor = resolveTaskRuntimeRead(root, task.id, "requirement.md"); } catch { throw new Error("Unsafe requirement path"); }
235
+ if (existsSync(cursor)) { const requirement = readFileSync(cursor, "utf8"); if (Buffer.byteLength(requirement) > 8192) throw new Error("Requirement exceeds intake limit"); intake = `## User requirement\n${requirement}`; }
235
236
  }
236
237
  let content = [`# AI-DLC phase packet — ${phase}`, `mode: ${options.mode ?? "standard"}${options.itemId ? ` · item: ${options.itemId}` : ""}`, "## Next action / stop contract", JSON.stringify(nextAction(task, root), null, 2), "## Phase contract", phaseContract, ...(intake ? [intake] : []), "## Canonical task state", compact, "## Resolved profiles", profiles.map((item) => JSON.stringify({ id: item.id, topology: item.topology, discovery: item.discovery, specs: item.specs, commands: item.commands })).join("\n"), "## Effective project configuration", JSON.stringify({ discovery: effective.discovery, specs: effective.specs, commands: effective.commands, risk: effective.risk, context: effective.context }, null, 2), "## Invariants", invariants].join("\n\n");
237
238
  if (content.length > config.context.maxChars) throw new Error("Context budget is too small for the full phase contract, next action, canonical task state, and mandatory invariants");
@@ -10,6 +10,8 @@
10
10
  * resumeTask(taskId) re-activate a paused task
11
11
  * listTasks() / getTask(id) / gate(id) read models for the dashboard (fleet)
12
12
  * approve({ taskId, source, reviewerToken }) fleet reviewer approval of the current snapshot
13
+ * feedback({ taskId, reason, source }) fleet human feedback that keeps the task at its gate
14
+ * (local dashboards call `state.mjs task status` directly)
13
15
  *
14
16
  * The adapters never approve a gate on their own and never fall back from one
15
17
  * authority to the other. Fleet credentials are scoped: the reviewer token is
@@ -24,7 +26,9 @@ import { isLoopProvider } from "./loop-drivers.mjs";
24
26
  import { deriveTaskIdentity, prepareLoopIntake } from "./loop-intake.mjs";
25
27
  import { readLoopRecord, validateLimits } from "./loop-runtime.mjs";
26
28
  import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
29
+ import { explainFleetConflict } from "./loop-holder.mjs";
27
30
  import { loadState, loadTask, nextAction } from "./store.mjs";
31
+ import { resolveTaskRuntimeRead, taskRuntimeDir } from "./task-runtime.mjs";
28
32
 
29
33
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
34
  const byUpdatedAt = (a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id);
@@ -33,15 +37,15 @@ const tasksDir = (root) => join(root, ".agents", "data", "tasks");
33
37
  /** The intake record written by `prepareLoopIntake`, or null. */
34
38
  export function readIntakeRecord(root, taskId) {
35
39
  if (!ID.test(taskId ?? "")) return null;
36
- const path = join(tasksDir(root), taskId, "loop-intake.json");
40
+ const path = resolveTaskRuntimeRead(root, taskId, "intake.json");
37
41
  try { if (!lstatSync(path).isFile()) return null; return JSON.parse(readFileSync(path, "utf8")); }
38
42
  catch (error) { if (error?.code === "ENOENT") return null; throw error; }
39
43
  }
40
44
 
41
45
  const stateError = (error) => {
42
46
  const stderr = String(error?.stderr ?? "").trim();
43
- try { const parsed = JSON.parse(stderr); if (parsed?.error?.message) return new Error(parsed.error.message); } catch { /* not JSON */ }
44
- return stderr ? new Error(stderr.split("\n").filter(Boolean).at(-1)) : error;
47
+ try { const parsed = JSON.parse(stderr); if (parsed?.error?.message) return new Error(explainFleetConflict(parsed.error.message)); } catch { /* not JSON */ }
48
+ return stderr ? new Error(explainFleetConflict(stderr.split("\n").filter(Boolean).at(-1))) : error;
45
49
  };
46
50
 
47
51
  /** Pick a unique, case-insensitive task id for an intake that did not name one. */
@@ -56,12 +60,14 @@ const uniqueTaskId = (root, slug, takenIds) => {
56
60
 
57
61
  /** Run intake and roll back the intake files this call created when no task came into being. */
58
62
  const guardedIntake = async (root, taskId, readTask, body) => {
59
- const files = [join(tasksDir(root), taskId, "loop-intake.json"), join(tasksDir(root), taskId, "requirement.md")];
63
+ const files = [resolveTaskRuntimeRead(root, taskId, "intake.json"), resolveTaskRuntimeRead(root, taskId, "requirement.md")];
60
64
  const preexisting = files.map((path) => existsSync(path));
65
+ const runtimeExisted = existsSync(taskRuntimeDir(root, taskId));
61
66
  try { return await body(); }
62
67
  catch (error) {
63
68
  if (!(await readTask(taskId))) {
64
69
  files.forEach((path, index) => { if (!preexisting[index]) { try { unlinkSync(path); } catch (cleanupError) { if (cleanupError?.code !== "ENOENT") throw cleanupError; } } });
70
+ if (!runtimeExisted) { try { rmdirSync(taskRuntimeDir(root, taskId)); } catch { /* not empty or already gone */ } }
65
71
  try { rmdirSync(join(tasksDir(root), taskId)); } catch { /* not empty or already gone */ }
66
72
  }
67
73
  throw error;
@@ -129,7 +135,7 @@ export function createLocalAuthority({ root, agent, workers = {}, workerImages =
129
135
 
130
136
  const FLEET_REQUIRED = ["AIDLC_FLEET_URL", "AIDLC_FLEET_WORKSPACE", "AIDLC_FLEET_TOKEN", "AIDLC_FLEET_INTEGRATION_BRANCH"];
131
137
  /** Credentials that must never reach a lead or worker process. */
132
- const REVIEWER_SECRETS = ["AIDLC_FLEET_REVIEWER_TOKEN", "AIDLC_UI_TOKEN", "AIDLC_UI_TOKEN_FILE", "AIDLC_UI_REVIEWER_TOKEN_FILE"];
138
+ const REVIEWER_SECRETS = ["AIDLC_FLEET_REVIEWER_TOKEN", "AIDLC_UI_REVIEWER_TOKEN_FILE"];
133
139
  const LIMIT_ENV = { maxTurns: "AIDLC_FLEET_MAX_TURNS", turnTimeoutMs: "AIDLC_FLEET_TURN_TIMEOUT_MS", maxStallTurns: "AIDLC_FLEET_MAX_STALL_TURNS" };
134
140
 
135
141
  /** Build the lead environment for fleet runs: validated service settings, the
@@ -226,6 +232,16 @@ export async function createFleetAuthority({ root, agent, workers = {}, limits,
226
232
  if (status !== "blocked_on_user" || !snapshot) throw new Error("Task has no current gate snapshot to approve");
227
233
  try { return JSON.parse(execFileSync(process.execPath, [remoteState, "gate", "approve", taskId, "--snapshot", snapshot.id, "--source", source], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...leadEnv, AIDLC_FLEET_REVIEWER_TOKEN: reviewerToken } })); }
228
234
  catch (error) { throw stateError(error); }
235
+ },
236
+ /** Human feedback at a gate: records the `Cancelled gate wait` evidence and re-activates the task
237
+ * so the loop revises and re-presents the same gate. Runs as the lead; never needs the reviewer credential. */
238
+ async feedback({ taskId, reason, source }) {
239
+ if (typeof reason !== "string" || !reason.trim()) throw new Error("Feedback reason is required");
240
+ if (typeof source !== "string" || !source.trim()) throw new Error("Feedback source is required");
241
+ const task = await readTask(taskId);
242
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
243
+ if (task.status !== "blocked_on_user" || task.gate === "none") throw new Error("Task is not waiting at a human gate");
244
+ return withLead(taskId, ["task", "status", taskId, "--status", "active", "--mode", "audited", "--reason", reason, "--source", source]);
229
245
  }
230
246
  };
231
247
  }