@felixdotgo/aidlc-kit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/DEVELOPMENT.md +14 -0
  2. package/README.md +11 -13
  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 +1 -1
  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/scripts/lib/loop-authority.mjs +245 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +15 -3
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +40 -15
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +57 -2
  28. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
  29. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  30. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  31. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  32. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  33. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  34. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  35. package/dist/assets/ui/bulma.min.css +3 -0
  36. package/dist/assets/ui/dashboard.css +414 -0
  37. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  38. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  39. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  40. package/dist/services/aidlc-coordination/package.json +9 -4
  41. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +57 -2
  42. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  43. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  44. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  45. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  46. package/dist/src/adapters.d.ts +0 -3
  47. package/dist/src/adapters.js +3 -5
  48. package/dist/src/cli/argv.d.ts +12 -0
  49. package/dist/src/cli/argv.js +62 -0
  50. package/dist/src/cli/commands.d.ts +8 -0
  51. package/dist/src/cli/commands.js +129 -0
  52. package/dist/src/cli/options.d.ts +25 -0
  53. package/dist/src/cli/options.js +32 -0
  54. package/dist/src/cli/prompt.d.ts +9 -0
  55. package/dist/src/cli/prompt.js +66 -0
  56. package/dist/src/cli.js +25 -191
  57. package/dist/src/gate.js +3 -2
  58. package/dist/src/installer.d.ts +14 -7
  59. package/dist/src/installer.js +38 -141
  60. package/dist/src/legacy.js +1 -0
  61. package/dist/src/managed-content.d.ts +7 -0
  62. package/dist/src/managed-content.js +15 -4
  63. package/dist/src/managed-planning.d.ts +65 -0
  64. package/dist/src/managed-planning.js +219 -0
  65. package/dist/src/mcp-setup.js +3 -1
  66. package/dist/src/model/config-contracts.d.ts +65 -0
  67. package/dist/src/model/config-contracts.js +1 -0
  68. package/dist/src/model/install-contracts.d.ts +29 -0
  69. package/dist/src/model/install-contracts.js +1 -0
  70. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  71. package/dist/src/model/lifecycle-enums.js +1 -0
  72. package/dist/src/model/loop.d.ts +20 -0
  73. package/dist/src/model/loop.js +1 -0
  74. package/dist/src/model/manifest.d.ts +16 -0
  75. package/dist/src/model/manifest.js +1 -0
  76. package/dist/src/model/records.d.ts +138 -0
  77. package/dist/src/model/records.js +1 -0
  78. package/dist/src/model.d.ts +11 -264
  79. package/dist/src/model.js +1 -1
  80. package/dist/src/profiles.d.ts +2 -0
  81. package/dist/src/profiles.js +53 -9
  82. package/dist/src/state/archive-record.d.ts +9 -0
  83. package/dist/src/state/archive-record.js +26 -0
  84. package/dist/src/state/commands.d.ts +18 -0
  85. package/dist/src/state/commands.js +27 -0
  86. package/dist/src/state/domain.d.ts +9 -0
  87. package/dist/src/state/domain.js +9 -0
  88. package/dist/src/state/guards.d.ts +7 -0
  89. package/dist/src/state/guards.js +17 -0
  90. package/dist/src/state/io.d.ts +10 -0
  91. package/dist/src/state/io.js +35 -0
  92. package/dist/src/state/items.d.ts +3 -4
  93. package/dist/src/state/items.js +58 -57
  94. package/dist/src/state/lessons.js +14 -7
  95. package/dist/src/state/lifecycle.js +2 -2
  96. package/dist/src/state/lock.js +7 -6
  97. package/dist/src/state/memory.js +24 -12
  98. package/dist/src/state/{archive.js → migrate.js} +7 -7
  99. package/dist/src/state/partition.d.ts +15 -0
  100. package/dist/src/state/partition.js +90 -0
  101. package/dist/src/state/paths.d.ts +11 -0
  102. package/dist/src/state/paths.js +12 -0
  103. package/dist/src/state/render.js +9 -6
  104. package/dist/src/state/store.js +9 -15
  105. package/dist/src/state/transitions.js +9 -7
  106. package/dist/src/state/validate.js +50 -7
  107. package/dist/src/state.d.ts +16 -11
  108. package/dist/src/state.js +16 -11
  109. package/dist/src/task-ui.d.ts +54 -0
  110. package/dist/src/task-ui.js +1570 -0
  111. package/dist/src/upgrade-board.js +10 -10
  112. package/dist/src/upgrade.d.ts +0 -1
  113. package/dist/src/upgrade.js +19 -83
  114. package/dist/src/workflow.js +3 -3
  115. package/docs/README.md +6 -2
  116. package/docs/architecture.md +9 -8
  117. package/docs/command-reference.md +81 -20
  118. package/docs/configuration.md +28 -8
  119. package/docs/coordination-service.md +18 -6
  120. package/docs/design-decisions.md +83 -0
  121. package/docs/development.md +27 -2
  122. package/docs/loop-local-run.md +495 -0
  123. package/docs/loop-runbook.md +235 -0
  124. package/docs/operating-workflow.md +2 -2
  125. package/docs/testing-and-release.md +5 -1
  126. package/docs/troubleshooting.md +125 -0
  127. package/package.json +4 -1
  128. package/dist/src/ledger.d.ts +0 -22
  129. package/dist/src/ledger.js +0 -119
  130. package/dist/src/state/shared.d.ts +0 -29
  131. package/dist/src/state/shared.js +0 -71
  132. package/dist/src/tracker.d.ts +0 -36
  133. package/dist/src/tracker.js +0 -44
  134. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
package/DEVELOPMENT.md CHANGED
@@ -12,6 +12,20 @@ npm test
12
12
  npm run lint
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).
28
+
15
29
  ## Contributor documentation
16
30
 
17
31
  - [Development guide](./docs/development.md) — dependencies, repository layout, implementation boundaries, and local commands.
package/README.md CHANGED
@@ -1,28 +1,33 @@
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)
4
+ [![license](https://img.shields.io/npm/l/@felixdotgo/aidlc-kit)](https://github.com/felixdotgo/aidlc-kit/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/@felixdotgo/aidlc-kit)](https://nodejs.org/)
6
+
3
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.
4
8
 
5
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.
6
10
 
7
11
  > [!WARNING]
8
- > The optional end-to-end loop and the AI-DLC Coordination service are **experimental and unfinished**: they contain known bugs and rough edges. The package is pre-1.0 (`0.x`), so their CLI flags, `loop.*`/`mcp.*` configuration fields, environment variables, and installed layouts can change incompatibly in any release before `1.0.0`.
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`.
9
13
 
10
14
  ## Requirements
11
15
 
12
16
  - Node.js 20.17 or newer.
13
17
  - An existing project directory.
14
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.
15
20
 
16
21
  ## Quick start
17
22
 
18
23
  Run these commands from your existing project's directory to preview the installation, then apply it:
19
24
 
20
25
  ```sh
21
- npx --package @felixdotgo/aidlc-kit@0.1.1 aidlc-kit init . --agent codex --dry-run
22
- npx --package @felixdotgo/aidlc-kit@0.1.1 aidlc-kit init . --agent codex --yes
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
23
28
  ```
24
29
 
25
- `npx` may ask permission to download the package on first use. These examples pin version `0.1.1`. After installation, the project's lifecycle scripts run locally without `npx` or a registry connection.
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.
26
31
 
27
32
  Use the adapter that matches the coding tool:
28
33
 
@@ -37,8 +42,8 @@ Use `--all` to install every supported adapter. Codex installs workspace-write s
37
42
  Confirm the installed project locally:
38
43
 
39
44
  ```sh
40
- npx --package @felixdotgo/aidlc-kit@0.1.1 aidlc-kit status .
41
- npx --package @felixdotgo/aidlc-kit@0.1.1 aidlc-kit doctor . --strict
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
42
47
  ```
43
48
 
44
49
  ## Documentation
@@ -58,13 +63,6 @@ The detailed documentation is organised by task and audience:
58
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
65
 
61
- ## Recent changes
62
-
63
- - Synced the two committed ESM lifecycle cores so `lifecycle-core-conformance` passes.
64
- - Normalized Git path scopes (trailing slashes stripped) consistently across fleet, local-worker, and snapshot flows; directory scopes now work in all three.
65
- - Hardened the state lock to reclaim partially written (corrupt) lock files instead of wedging mutations.
66
- - Made the fleet end-to-end test skip cleanly when its Docker worker image is unavailable.
67
-
68
66
  ## License
69
67
 
70
68
  MIT
@@ -0,0 +1,7 @@
1
+ # AI-DLC worker image (Claude Code). Shipped and managed by aidlc-kit — build it
2
+ # with `aidlc-kit loop worker-image --provider claude` instead of by hand.
3
+ #
4
+ # The agent CLI must be on the system PATH: workers run non-root with a read-only
5
+ # root filesystem, so a global npm install lands the binary in /usr/local/bin.
6
+ FROM node:24-bookworm-slim
7
+ RUN npm install -g @anthropic-ai/claude-code
@@ -0,0 +1,9 @@
1
+ # AI-DLC worker image (OpenAI Codex CLI). Shipped and managed by aidlc-kit —
2
+ # build it with `aidlc-kit loop worker-image --provider codex` instead of by hand.
3
+ #
4
+ # The agent CLI must be on the system PATH: workers run non-root with a read-only
5
+ # root filesystem, so a global npm install lands the binary in /usr/local/bin.
6
+ # Use the scoped `@openai/codex` package (the unscoped `codex` npm package is
7
+ # unrelated).
8
+ FROM node:24-bookworm-slim
9
+ RUN npm install -g @openai/codex
@@ -0,0 +1,8 @@
1
+ # AI-DLC worker image (OpenCode 2). Shipped and managed by aidlc-kit — build it
2
+ # with `aidlc-kit loop worker-image --provider opencode` instead of by hand.
3
+ #
4
+ # The agent CLI must be on the system PATH: workers run non-root with a read-only
5
+ # root filesystem, so a global npm install lands the binary in /usr/local/bin.
6
+ # Use `@opencode/cli` (OpenCode 2) — NOT `opencode-ai` (OpenCode 1).
7
+ FROM node:24-bookworm-slim
8
+ RUN npm install -g @opencode/cli
@@ -52,7 +52,7 @@ Economy models use the same bounded packet contract, gates, executable checks, a
52
52
  - The lead is the single writer for lifecycle state, gate actions, and task review artifacts. Workers never approve or present gates, mutate lifecycle records, or make canonical artifact decisions; every human-gate and state-machine invariant remains in force.
53
53
  - Each worker assignment must name a bounded objective, owned paths or read-only scope, dependencies, expected verification, and required return output (summary, changed paths, evidence, and concerns). The lead integrates and verifies worker results before recording evidence or advancing the task.
54
54
  - In local interactive mode, before dispatching a canonical build item, run `assignment.mjs claim <task-id> <item-id> --worker <worker-id> --paths <comma-separated-owned-paths>`. Pass the returned assignment ID to the native worker. Use `assignment.mjs heartbeat` for long work and `assignment.mjs report` when it returns. After integration and canonical item completion, run `assignment.mjs reconcile`. An expired claim becomes `uncertain`; inspect its files and report before `assignment.mjs release` and any retry. Do not use assignments to store approvals or mark canonical items done.
55
- - Assign one writer per path. Partition work so concurrent changes cannot overlap; workers may inspect shared files, but must report a needed cross-owner change to the lead rather than edit it. The lead resolves conflicts and performs final integration.
55
+ - Assign one writer per path. Partition work so concurrent changes cannot overlap; workers may inspect shared files, but must report a needed cross-owner change to the lead rather than edit it. The lead resolves conflicts and performs final integration. Canonical build items carry this partition as `ownedPaths`/`dependsOn`; the Approve plan gate check reports `PARTITION_OVERLAP`/`PARTITION_SCOPE` when concurrently-runnable items overlap or lack owned paths.
56
56
 
57
57
  ## Customization layers
58
58
 
@@ -7,7 +7,7 @@ When independent, read-only index or ambiguity checks exist, use the shared orch
7
7
  1. Read the compact repository/spec indexes if available; do not scan implementation code.
8
8
  2. Read `templates/model-contract.md` and apply its COSTARS clarify emphasis to the intent brief. Use CRITICS only for elevated risk, ambiguity, or model disagreement.
9
9
  3. Canonical task state already exists from turn routing (`task create` runs there, before this packet). Never create it a second time; a repeated create fails with `Task already exists`.
10
- 4. Fill the intent template: problem, affected areas, assumptions, open questions with safe defaults, and decidable in/out scope.
10
+ 4. Fill the intent template: problem, affected areas, work boundaries (independent streams and the paths each touches, or `single stream` with the reason), assumptions, open questions with safe defaults, and decidable in/out scope.
11
11
  5. Run `node .agents/aidlc/scripts/gate-check.mjs <task-id> --gate G0_confirm` and fix every structural error. Only after it passes, set the gate wait with `node .agents/aidlc/scripts/state.mjs task status <task-id> --status blocked_on_user`, then render only this task with `node .agents/aidlc/scripts/render.mjs <task-id>`.
12
12
  6. Run `node .agents/aidlc/scripts/gate-view.mjs <task-id>`, relay the output verbatim, and stop. Presenting the gate ends this turn.
13
13
 
@@ -6,9 +6,9 @@ Use the shared orchestration contract in `orchestrator.md` to fan out only indep
6
6
 
7
7
  1. Retrieve only matched specs and impacted symbols. Parallelize bounded discovery when available; return summaries and exact spec anchors, not file dumps.
8
8
  2. Read `templates/model-contract.md` and apply its COSTARS plan emphasis. Use CRITICS only for elevated risk, ambiguity, or model disagreement.
9
- 3. Fill the design template with the solution per affected area, exact sources for quantified rules, contracts, risks, and reuse candidates.
9
+ 3. Fill the design template with the solution per affected area, exact sources for quantified rules, contracts, risks, reuse candidates, and the Parallel work partition table matching the canonical items.
10
10
  4. Add every real judgement call with `node .agents/aidlc/scripts/state.mjs decision set <task-id> <decision-id> --status unresolved --label <label> --resolution <proposed-default>`. Code/spec conflicts and ambiguous enumerations are always decisions.
11
- 5. Add implementation items with `node .agents/aidlc/scripts/state.mjs task item <task-id> <item-id> --status todo --label <label>` and configure narrow verification commands; render the workplan review artifact with `node .agents/aidlc/scripts/render.mjs <task-id>`.
11
+ 5. Add implementation items with `node .agents/aidlc/scripts/state.mjs task item <task-id> <item-id> --status todo --label <label> --owns <comma-separated-paths> [--depends-on <item-ids>]` and configure narrow verification commands. Every item passes `--owns`; add `--depends-on` whenever it must touch a path that another concurrently-runnable item owns. With the loop enabled, the Approve plan gate check fails with `PARTITION_OVERLAP`/`PARTITION_SCOPE` until fixed (warning when the loop is off). Then render the workplan review artifact with `node .agents/aidlc/scripts/render.mjs <task-id>`.
12
12
  6. Unresolved decisions block the Approve plan gate structurally. Present them to the user and apply each answer with `decision set <task-id> <decision-id> --status approved|changed|dropped --resolution <user-resolution>`. Do not treat a decision answer as gate approval.
13
13
  7. When every decision is resolved, run `node .agents/aidlc/scripts/gate-check.mjs <task-id> --gate G1_review` and fix every structural error. Only after it passes, set the gate wait with `node .agents/aidlc/scripts/state.mjs task status <task-id> --status blocked_on_user`, run `node .agents/aidlc/scripts/gate-view.mjs <task-id>`, relay it verbatim, and stop. Approve plan always requires explicit human approval.
14
14
 
@@ -13,7 +13,8 @@
13
13
  "context": { "type": "object", "required": ["maxChars"], "properties": { "maxChars": { "type": "integer", "minimum": 4000, "maximum": 128000 } } },
14
14
  "agentState": { "type": "object", "additionalProperties": { "enum": ["native", "scripted"] } },
15
15
  "tracker": { "type": "object", "additionalProperties": false, "required": ["enabled"], "properties": { "enabled": { "type": "boolean" }, "provider": { "enum": ["backlog"] }, "spaceUrl": { "type": "string", "format": "uri" }, "project": { "type": "string", "minLength": 1 }, "tokenEnv": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "workflow": { "enum": ["waterfall", "scrum"] }, "mapping": { "type": "object", "properties": { "statuses": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, "gateFieldId": { "type": "integer", "minimum": 1 } } } } },
16
- "mcp": { "type": "object", "additionalProperties": false, "required": ["enabled"], "properties": { "enabled": { "type": "boolean" }, "endpoint": { "type": "string", "format": "uri" }, "workspace": { "type": "string", "minLength": 1 }, "tokenEnv": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "pollMs": { "type": "integer", "minimum": 1000, "maximum": 3600000 }, "providers": { "type": "array", "items": { "enum": ["jira", "trello", "github-issues"] }, "uniqueItems": true } } }
16
+ "mcp": { "type": "object", "additionalProperties": false, "required": ["enabled"], "properties": { "enabled": { "type": "boolean" }, "endpoint": { "type": "string", "format": "uri" }, "workspace": { "type": "string", "minLength": 1 }, "tokenEnv": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "pollMs": { "type": "integer", "minimum": 1000, "maximum": 3600000 }, "providers": { "type": "array", "items": { "enum": ["jira", "trello", "github-issues"] }, "uniqueItems": true } } },
17
+ "$schema": { "type": "string", "maxLength": 512 }
17
18
  },
18
19
  "$defs": {
19
20
  "command": {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Loop authority adapters: one interface the shared loop daemon, intake, and
3
+ * dashboard actions use for both `local` and `fleet` modes.
4
+ *
5
+ * mode "local" | "fleet"
6
+ * listActionable() loop-owned tasks whose nextAction is run_phase (oldest first)
7
+ * runTask({ taskId, signal, cancelSignal, onEvent })
8
+ * one supervised run until a gate, block, pause, completion or stop
9
+ * intake({ requirement, taskId?, title? }) create a loop-owned task without running it
10
+ * resumeTask(taskId) re-activate a paused task
11
+ * listTasks() / getTask(id) / gate(id) read models for the dashboard (fleet)
12
+ * approve({ taskId, source, reviewerToken }) fleet reviewer approval of the current snapshot
13
+ *
14
+ * The adapters never approve a gate on their own and never fall back from one
15
+ * authority to the other. Fleet credentials are scoped: the reviewer token is
16
+ * stripped from every lead/worker environment and is passed only to the single
17
+ * `remote-state.mjs gate approve` child of `approve`.
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, unlinkSync } from "node:fs";
21
+ import { join, resolve } from "node:path";
22
+ import { requireLocalAuthority } from "./authority.mjs";
23
+ import { isLoopProvider } from "./loop-drivers.mjs";
24
+ import { deriveTaskIdentity, prepareLoopIntake } from "./loop-intake.mjs";
25
+ import { readLoopRecord, validateLimits } from "./loop-runtime.mjs";
26
+ import { createIsolatedLocalSupervisor } from "./loop-supervisor-run-local.mjs";
27
+ import { loadState, loadTask, nextAction } from "./store.mjs";
28
+
29
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
+ const byUpdatedAt = (a, b) => String(a.updatedAt ?? "").localeCompare(String(b.updatedAt ?? "")) || a.id.localeCompare(b.id);
31
+ const tasksDir = (root) => join(root, ".agents", "data", "tasks");
32
+
33
+ /** The intake record written by `prepareLoopIntake`, or null. */
34
+ export function readIntakeRecord(root, taskId) {
35
+ if (!ID.test(taskId ?? "")) return null;
36
+ const path = join(tasksDir(root), taskId, "loop-intake.json");
37
+ try { if (!lstatSync(path).isFile()) return null; return JSON.parse(readFileSync(path, "utf8")); }
38
+ catch (error) { if (error?.code === "ENOENT") return null; throw error; }
39
+ }
40
+
41
+ const stateError = (error) => {
42
+ 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;
45
+ };
46
+
47
+ /** Pick a unique, case-insensitive task id for an intake that did not name one. */
48
+ const uniqueTaskId = (root, slug, takenIds) => {
49
+ const dirs = existsSync(tasksDir(root)) ? readdirSync(tasksDir(root)) : [];
50
+ const existing = new Set([...takenIds, ...dirs].map((id) => id.toLowerCase()));
51
+ const base = slug.slice(0, 120).replace(/-+$/, "") || "loop-task";
52
+ let taskId = slug;
53
+ for (let suffix = 2; existing.has(taskId.toLowerCase()); suffix += 1) taskId = `${base}-${suffix}`;
54
+ return taskId;
55
+ };
56
+
57
+ /** Run intake and roll back the intake files this call created when no task came into being. */
58
+ const guardedIntake = async (root, taskId, readTask, body) => {
59
+ const files = [join(tasksDir(root), taskId, "loop-intake.json"), join(tasksDir(root), taskId, "requirement.md")];
60
+ const preexisting = files.map((path) => existsSync(path));
61
+ try { return await body(); }
62
+ catch (error) {
63
+ if (!(await readTask(taskId))) {
64
+ files.forEach((path, index) => { if (!preexisting[index]) { try { unlinkSync(path); } catch (cleanupError) { if (cleanupError?.code !== "ENOENT") throw cleanupError; } } });
65
+ try { rmdirSync(join(tasksDir(root), taskId)); } catch { /* not empty or already gone */ }
66
+ }
67
+ throw error;
68
+ }
69
+ };
70
+
71
+ const validRequirement = (requirement) => {
72
+ if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
73
+ };
74
+
75
+ /* ---------------------------------------------------------------- local */
76
+
77
+ /** A task is loop-owned locally when it has an intake record or a loop run record. */
78
+ export function isLocalLoopOwned(root, taskId) {
79
+ if (!ID.test(taskId ?? "")) return false;
80
+ const record = readIntakeRecord(root, taskId);
81
+ if (record) return record.authority !== "fleet";
82
+ return readLoopRecord(root, taskId) !== null;
83
+ }
84
+
85
+ export function createLocalAuthority({ root, agent, workers = {}, workerImages = {}, limits, pollMs, supervisor = createIsolatedLocalSupervisor } = {}) {
86
+ requireLocalAuthority();
87
+ const stateScript = join(root, ".agents/aidlc/scripts/state.mjs");
88
+ const owned = (id) => { try { return isLocalLoopOwned(root, id); } catch { return true; } };
89
+ return {
90
+ mode: "local",
91
+ isOwned: owned,
92
+ async listActionable() {
93
+ return Object.values(loadState(root).tasks).filter((task) => nextAction(task, root).classification === "run_phase" && owned(task.id)).sort(byUpdatedAt);
94
+ },
95
+ async runTask({ taskId, signal, cancelSignal, onEvent }) {
96
+ const run = supervisor({ root, taskId, agent, workers, workerImages, limits, pollMs, signal, cancelSignal, untilStop: true, onEvent });
97
+ return readLoopRecord(root, taskId) ? run.resume() : run.start();
98
+ },
99
+ /** Interactive work keeps priority: intake refuses while a non-loop task is actionable. */
100
+ async intake({ requirement, taskId: requested, title: requestedTitle }) {
101
+ validRequirement(requirement);
102
+ const identity = deriveTaskIdentity(requirement);
103
+ const state = loadState(root);
104
+ const taskId = requested || uniqueTaskId(root, identity.slug, [...Object.keys(state.tasks), ...Object.keys(state.archive ?? {})]);
105
+ if (!ID.test(taskId)) throw new Error("Valid task ID required");
106
+ const title = requestedTitle || identity.title;
107
+ const actionableIds = (current) => Object.values(current.tasks).filter((task) => task.id !== taskId && nextAction(task, root).classification === "run_phase").map((task) => task.id).sort();
108
+ const interactive = actionableIds(state).filter((id) => !owned(id));
109
+ if (interactive.length) throw new Error(`Task create is blocked while interactive work is actionable: ${interactive.join(", ")}. Finish or pause it before starting loop work`);
110
+ const readTask = async (id) => loadTask(root, id);
111
+ await guardedIntake(root, taskId, readTask, () => prepareLoopIntake({ root, taskId, requirement, title, authority: "local", readTask, createTask: async ({ taskId: id, title: taskTitle }) => {
112
+ const loopOwned = actionableIds(loadState(root)).filter((other) => owned(other));
113
+ try { execFileSync(process.execPath, [stateScript, "task", "create", id, "--title", taskTitle, ...(loopOwned.length ? ["--switch-from", loopOwned.join(",")] : []), "--root", root], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }); }
114
+ catch (error) { throw stateError(error); }
115
+ } }));
116
+ return { ok: true, taskId, title, authority: "local" };
117
+ },
118
+ async resumeTask(taskId) {
119
+ const task = loadTask(root, taskId);
120
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
121
+ if (task.status !== "paused" || task.handoff) throw new Error(`Only a paused task without a handoff can be resumed (status: ${task.status})`);
122
+ try { return JSON.parse(execFileSync(process.execPath, [stateScript, "task", "status", taskId, "--status", "active", "--root", root], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" })); }
123
+ catch (error) { throw stateError(error); }
124
+ }
125
+ };
126
+ }
127
+
128
+ /* ---------------------------------------------------------------- fleet */
129
+
130
+ const FLEET_REQUIRED = ["AIDLC_FLEET_URL", "AIDLC_FLEET_WORKSPACE", "AIDLC_FLEET_TOKEN", "AIDLC_FLEET_INTEGRATION_BRANCH"];
131
+ /** 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"];
133
+ const LIMIT_ENV = { maxTurns: "AIDLC_FLEET_MAX_TURNS", turnTimeoutMs: "AIDLC_FLEET_TURN_TIMEOUT_MS", maxStallTurns: "AIDLC_FLEET_MAX_STALL_TURNS" };
134
+
135
+ /** Build the lead environment for fleet runs: validated service settings, the
136
+ * configured roster, `loop.limits`/`loop.pollMs` as defaults (explicit
137
+ * `AIDLC_FLEET_*` env wins), and no reviewer credentials. */
138
+ export function fleetLeadEnv({ root, agent, workers, limits, pollMs, env = process.env }) {
139
+ for (const name of FLEET_REQUIRED) if (typeof env[name] !== "string" || !env[name].trim()) throw new Error(`Fleet loop requires ${name} for the configured remote authority`);
140
+ if (env.AIDLC_FLEET_PROJECT_ROOT && resolve(env.AIDLC_FLEET_PROJECT_ROOT) !== root) throw new Error("AIDLC_FLEET_PROJECT_ROOT does not match the loop root; refusing to run");
141
+ const workerIds = Object.keys(workers).join(",");
142
+ if (env.AIDLC_FLEET_WORKER_IDS && env.AIDLC_FLEET_WORKER_IDS.split(",").map((value) => value.trim()).filter(Boolean).join(",") !== workerIds) throw new Error("AIDLC_FLEET_WORKER_IDS does not match the configured loop workers");
143
+ if (env.AIDLC_FLEET_WORKER_PROVIDERS) {
144
+ // Compare content, not the JSON string: key order and spacing do not matter.
145
+ let mapping; try { mapping = JSON.parse(env.AIDLC_FLEET_WORKER_PROVIDERS); } catch { mapping = null; }
146
+ const same = mapping && typeof mapping === "object" && !Array.isArray(mapping) && Object.keys(mapping).length === Object.keys(workers).length && Object.entries(workers).every(([id, provider]) => mapping[id] === provider);
147
+ if (!same) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS does not match the configured loop workers");
148
+ }
149
+ const lead = { ...env, AIDLC_FLEET_PROJECT_ROOT: root, AIDLC_FLEET_WORKER_IDS: workerIds, AIDLC_FLEET_WORKER_PROVIDERS: JSON.stringify(workers), AIDLC_FLEET_AGENT: agent };
150
+ for (const name of REVIEWER_SECRETS) delete lead[name];
151
+ for (const [key, name] of Object.entries(LIMIT_ENV)) if (limits?.[key] !== undefined && lead[name] === undefined) lead[name] = String(limits[key]);
152
+ if (pollMs !== undefined && lead.AIDLC_FLEET_POLL_MS === undefined) lead.AIDLC_FLEET_POLL_MS = String(pollMs);
153
+ return lead;
154
+ }
155
+
156
+ export async function createFleetAuthority({ root, agent, workers = {}, limits, pollMs, env = process.env, callTool: callToolOverride, runCli } = {}) {
157
+ if (!isLoopProvider(agent)) throw new Error("Loop requires a configured lead and worker providers");
158
+ const leadEnv = fleetLeadEnv({ root, agent, workers, limits, pollMs, env });
159
+ const url = leadEnv.AIDLC_FLEET_URL, token = leadEnv.AIDLC_FLEET_TOKEN, workspace = leadEnv.AIDLC_FLEET_WORKSPACE;
160
+ const remoteState = join(root, ".agents/aidlc/scripts/remote-state.mjs");
161
+ if (!existsSync(remoteState)) throw new Error(`Remote lifecycle CLI is missing: ${remoteState}`);
162
+ const { callRemoteTool } = await import("./remote-state-client.mjs");
163
+ const { createFleetRemoteRepository } = await import("./loop-fleet-remote.mjs");
164
+ const callTool = callToolOverride ?? (({ url: serviceUrl, token: serviceToken, name, args }) => callRemoteTool({ url: serviceUrl, token: serviceToken }, name, args));
165
+ const repository = createFleetRemoteRepository({ callTool, url, token, workspace, root });
166
+ const fleetCli = runCli ?? (await import("./loop-fleet-cli.mjs")).runFleetCli;
167
+ const owned = (id) => readIntakeRecord(root, id)?.authority === "fleet";
168
+ /** Run one remote lifecycle command under a short lead claim (task create/status). */
169
+ const withLead = async (taskId, args) => {
170
+ const claim = await repository.claimLead({ taskId, leaseMs: 60000 });
171
+ try { return JSON.parse(execFileSync(process.execPath, [remoteState, ...args], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...leadEnv, AIDLC_FLEET_LEAD_EPOCH: String(claim.epoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() } })); }
172
+ catch (error) { throw stateError(error); }
173
+ finally { await repository.releaseLead({ taskId, epoch: claim.epoch }); }
174
+ };
175
+ const readTask = async (id) => { try { return (await repository.readTask({ taskId: id }))?.task ?? null; } catch (error) { if (String(error?.message ?? error).includes("task not found")) return null; throw error; } };
176
+ return {
177
+ mode: "fleet",
178
+ repository,
179
+ isOwned: owned,
180
+ /** Fail closed before starting: the service must answer and the integration branch must exist on the remote. */
181
+ async preflight() {
182
+ await callTool({ url, token, name: "fleet_get", args: { workspace } });
183
+ const { readIntegrationCommit } = await import("../remote-state.mjs");
184
+ await readIntegrationCommit({ projectRoot: root, integrationBranch: leadEnv.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: leadEnv.AIDLC_FLEET_GIT_REMOTE ?? "origin" });
185
+ },
186
+ async listActionable() {
187
+ return (await repository.listTasks()).filter((task) => task.status === "active" && !task.handoff && owned(task.id) && nextAction(task, root).classification === "run_phase").sort(byUpdatedAt);
188
+ },
189
+ /** Bounded supervised run: returns at a gate (`gate`/`waiting`) instead of waiting for the reviewer. */
190
+ async runTask({ taskId, signal, cancelSignal, onEvent }) {
191
+ const result = await fleetCli(["supervisor", taskId], { callTool, env: leadEnv, signal, cancelSignal, untilStop: true, onEvent });
192
+ // The fleet supervisor already emitted its `gate` event through onEvent.
193
+ return result && typeof result === "object" ? { ...result, gateEmitted: true } : result;
194
+ },
195
+ async intake({ requirement, taskId: requested, title: requestedTitle }) {
196
+ validRequirement(requirement);
197
+ const identity = deriveTaskIdentity(requirement);
198
+ const taskId = requested || uniqueTaskId(root, identity.slug, (await repository.listTasks()).map((task) => task.id));
199
+ if (!ID.test(taskId)) throw new Error("Valid task ID required");
200
+ const title = requestedTitle || identity.title;
201
+ await guardedIntake(root, taskId, readTask, () => prepareLoopIntake({ root, taskId, requirement, title, authority: "fleet", readTask, createTask: async ({ taskId: id, title: taskTitle }) => { await withLead(id, ["task", "create", id, "--title", taskTitle]); } }));
202
+ return { ok: true, taskId, title, authority: "fleet" };
203
+ },
204
+ async resumeTask(taskId) {
205
+ const task = await readTask(taskId);
206
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
207
+ if (task.status !== "paused" || task.handoff) throw new Error(`Only a paused task without a handoff can be resumed (status: ${task.status})`);
208
+ return withLead(taskId, ["task", "status", taskId, "--status", "active"]);
209
+ },
210
+ async listTasks() { return (await repository.listTasks()).map((task) => ({ ...task, loopOwned: owned(task.id) })); },
211
+ async getTask(taskId) { const snapshot = await repository.readTask({ taskId }); return { task: snapshot?.task ?? null, nextAction: snapshot?.nextAction ?? null, revision: snapshot?.revision ?? null }; },
212
+ /** The current review snapshot for a waiting task (lead-readable inbox + checkpoint). */
213
+ async gate(taskId) {
214
+ const task = await readTask(taskId);
215
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
216
+ const checkpoint = await repository.readGateCheckpoint({ taskId });
217
+ const inbox = await callTool({ url, token, name: "gate_inbox_get", args: { workspace } });
218
+ const snapshot = Array.isArray(inbox) ? inbox.find((entry) => entry.taskId === taskId && entry.gate === task.gate && (!checkpoint?.snapshotId || entry.id === checkpoint.snapshotId)) ?? null : null;
219
+ return { taskId, status: task.status, gate: task.gate, checkpoint, snapshot };
220
+ },
221
+ /** Reviewer approval of the current snapshot. The reviewer token reaches only this child process. */
222
+ async approve({ taskId, source, reviewerToken }) {
223
+ if (typeof reviewerToken !== "string" || reviewerToken.length < 16) throw new Error("Fleet gate approval requires the reviewer credential (AIDLC_FLEET_REVIEWER_TOKEN)");
224
+ if (typeof source !== "string" || !source.trim()) throw new Error("Approval source is required");
225
+ const { snapshot, status } = await this.gate(taskId);
226
+ if (status !== "blocked_on_user" || !snapshot) throw new Error("Task has no current gate snapshot to approve");
227
+ 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
+ catch (error) { throw stateError(error); }
229
+ }
230
+ };
231
+ }
232
+
233
+ /** Create the authority for the configured loop mode; never falls back to the other mode. */
234
+ export async function createLoopAuthority({ root, config, env = process.env, ...overrides }) {
235
+ const mode = config?.loop?.mode;
236
+ const agent = config?.loop?.agents?.lead;
237
+ const workers = config?.loop?.agents?.workers ?? {};
238
+ // Fail fast on a bad roster or `loop.limits` instead of failing every task run.
239
+ if (!isLoopProvider(agent) || !workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length || Object.entries(workers).some(([id, provider]) => !ID.test(id) || !isLoopProvider(provider))) throw new Error("Loop requires a configured lead and worker providers");
240
+ if (config?.loop?.limits !== undefined) validateLimits(config.loop.limits);
241
+ const options = { root, agent, workers, workerImages: config?.loop?.workerImage ?? {}, limits: config?.loop?.limits, pollMs: config?.loop?.pollMs, ...overrides };
242
+ if (mode === "local") return createLocalAuthority(options);
243
+ if (mode === "fleet") return createFleetAuthority({ ...options, env });
244
+ throw new Error("Loop mode is off; configure an enabled loop mode first");
245
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Conventional Commit message extraction for worker Git commits.
3
+ *
4
+ * The worker may author the commit message by ending its final answer with a
5
+ * dedicated line (`AI-DLC-COMMIT: type(scope): subject`) next to the existing
6
+ * `AI-DLC-ITEM-COMPLETE` completion marker. The supervisor parses and validates
7
+ * that message; when it is absent or malformed it derives a deterministic,
8
+ * conventional fallback from the item id and owned paths instead of reusing the
9
+ * legacy `AI-DLC <task>/<item>` form. This module is dependency-free like the
10
+ * rest of the installed loop runtime.
11
+ */
12
+
13
+ const COMMIT_LINE = /^AI-DLC-COMMIT:\s*(.+?)\s*$/m;
14
+ const TYPE = "(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)";
15
+ const SCOPE = "\\([^()\\r\\n]{1,64}\\)";
16
+ const MESSAGE = new RegExp(`^${TYPE}(${SCOPE})?!?: [^\\r\\n]{1,200}$`);
17
+ const CONTROL = /[\u0000-\u001f\u007f]/;
18
+
19
+ function fallbackScope(ownedPaths) {
20
+ if (!Array.isArray(ownedPaths) || !ownedPaths.length) return "";
21
+ const topLevels = ownedPaths.map((path) => String(path).split("/")[0]);
22
+ const unique = [...new Set(topLevels)];
23
+ // Use a scope only when every owned path sits under one real top-level
24
+ // directory; a bare file (no "/") or a split across directories is ambiguous.
25
+ if (unique.length !== 1 || !ownedPaths.every((path) => String(path).includes("/"))) return "";
26
+ return unique[0];
27
+ }
28
+
29
+ /**
30
+ * Return a Conventional Commits message for a worker commit. When the worker's
31
+ * final answer carries a valid `AI-DLC-COMMIT:` line that message is used
32
+ * verbatim; otherwise a fallback `chore(<scope>): <item-id>` is derived.
33
+ */
34
+ export function extractCommitMessage(resultText, { itemId, ownedPaths = [] } = {}) {
35
+ if (typeof resultText === "string") {
36
+ const match = resultText.match(COMMIT_LINE);
37
+ if (match) {
38
+ const message = match[1].trim();
39
+ if (MESSAGE.test(message) && !CONTROL.test(message)) return message;
40
+ }
41
+ }
42
+ const scope = fallbackScope(ownedPaths);
43
+ const subject = typeof itemId === "string" && itemId.trim() ? itemId.trim() : "update";
44
+ return scope ? `chore(${scope}): ${subject}` : `chore: ${subject}`;
45
+ }