@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.
- package/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
package/DEVELOPMENT.md
CHANGED
|
@@ -1,45 +1,32 @@
|
|
|
1
1
|
# Contributing to aidlc-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Entry point for contributors. Details live in [`docs/`](./docs/README.md).
|
|
4
4
|
|
|
5
5
|
## Local bootstrap
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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) —
|
|
32
|
-
- [Testing and release](./docs/testing-and-release.md) — focused tests, package smoke test,
|
|
33
|
-
- [Architecture](./docs/architecture.md) — module ownership
|
|
34
|
-
- [
|
|
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
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
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
|
-
|
|
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
|
-
[](https://www.npmjs.com/package/@felixdotgo/aidlc-kit)
|
|
4
4
|
[](https://github.com/felixdotgo/aidlc-kit/blob/main/LICENSE)
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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.
|
|
27
|
-
npx --package @felixdotgo/aidlc-kit@0.
|
|
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
|
-
`
|
|
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
|
-
|
|
23
|
+
Then ask your agent for a change:
|
|
33
24
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
## Run the loop (optional)
|
|
43
32
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [
|
|
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
|
|
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/
|
|
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,
|
|
4
|
-
import { dirname, join
|
|
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
|
-
|
|
15
|
-
|
|
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 =
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
232
|
-
let cursor
|
|
233
|
-
|
|
234
|
-
if (
|
|
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 =
|
|
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 = [
|
|
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", "
|
|
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
|
}
|