@dtlabs/ai-toolkit 0.13.0 → 0.14.1
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/CHANGELOG.md +52 -0
- package/bin/cli.js +371 -2
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +102 -0
- package/lib/execution-ledger.js +186 -4
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +225 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-intervention-documentation-standard.md +9 -9
- package/src/claude/agents/gaia-validate-feature-docs.md +31 -28
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/implement-feature/SKILL.md +74 -159
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -358
- package/src/claude/workflows/pm-phase2.js +50 -571
- package/src/claude/workflows/pm-phase3.js +5 -824
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Task Executor CLI
|
|
2
|
+
|
|
3
|
+
> Documents the **real, currently-implemented** behavior of `ai-toolkit executor <subcommand>`
|
|
4
|
+
> (`bin/cli.js`'s `handleExecutorCommand`, wired to `lib/task-executor/index.js`), part of
|
|
5
|
+
> FTR-018 (deterministic task execution, checkpoints and resume).
|
|
6
|
+
>
|
|
7
|
+
> This file describes what the code actually does today, not the aspirational CLI contract in
|
|
8
|
+
> `FTR-018-Tech-Spec.md` section 10. Every discrepancy between the two is called out explicitly
|
|
9
|
+
> below rather than silently reconciled.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Invocation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ai-toolkit executor <subcommand> [flags]
|
|
17
|
+
# or, inside this repo:
|
|
18
|
+
node bin/cli.js executor <subcommand> [flags]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--project <path>` is accepted by every subcommand and defaults to `process.cwd()` when omitted.
|
|
22
|
+
All other flags are per-subcommand (see below). Any flag not explicitly recognized for a
|
|
23
|
+
subcommand is silently ignored — there is no generic flag-forwarding path, so nothing reaches a
|
|
24
|
+
filesystem path or lock primitive except the whitelisted flags read via `getFlag()`/`hasFlag()`.
|
|
25
|
+
|
|
26
|
+
**Discrepancy vs. Tech-Spec:** section 10 documents `--format json or text (default)` as a
|
|
27
|
+
common flag. The real CLI has no `--format` flag at all — `handleExecutorCommand` always emits
|
|
28
|
+
JSON (`JSON.stringify(result, null, 2)`) on success and always emits JSON on error. There is no
|
|
29
|
+
text-mode output in the current implementation.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
### `start`
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ai-toolkit executor start --project <path> --feature <path> [--max-concurrency N] [--claude-path <path>] [--task-timeout-ms <ms>] [--agent-budget-usd <amount>]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Flag | Required | Default | Notes |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `--project` | no | `process.cwd()` | Repo root; also resolves the common Git directory |
|
|
44
|
+
| `--feature` | **yes** | — | Path to the feature's `feature.md`; the CLI usage-errors (exit 2, no JSON) if omitted |
|
|
45
|
+
| `--max-concurrency` | no | `1` | Forwarded to `execute()` as a Number; only `1` is currently supported (see below) |
|
|
46
|
+
| `--claude-path` | no | — | Absolute path to the qualified Claude CLI executable |
|
|
47
|
+
| `--task-timeout-ms` | no | — | Forwarded as a Number |
|
|
48
|
+
| `--agent-budget-usd` | no | — | Forwarded as a Number; persisted into run config, not itself enforced |
|
|
49
|
+
|
|
50
|
+
Backs `lib/task-executor/index.js`'s `execute()`. Validates gates/config, acquires the
|
|
51
|
+
repo-wide execution lease for a brand-new run, builds an immutable plan snapshot from the
|
|
52
|
+
feature's own `{PREFIX}-Work-Breakdown.md/.csv`, and drives a sequential (slot count 1)
|
|
53
|
+
dispatch → verify → review → checkpoint loop to completion or block.
|
|
54
|
+
|
|
55
|
+
Only `maxConcurrency=1` is implemented — any other requested value is **rejected**
|
|
56
|
+
(`UNSUPPORTED_CONCURRENCY`), never silently downgraded. `execute()` also refuses to run at all
|
|
57
|
+
on any platform other than `win32` (see "Platform qualification" below), before touching any
|
|
58
|
+
other validation.
|
|
59
|
+
|
|
60
|
+
### `status`
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
ai-toolkit executor status --project <path> --run-id <uuid>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--run-id` is required (usage error, exit 2, if omitted). Read-only summary of a run: task
|
|
67
|
+
status counts, current run status, and total cost from the ledger.
|
|
68
|
+
|
|
69
|
+
**Not implemented.** `status()` in `lib/task-executor/index.js` is currently a stub that always
|
|
70
|
+
throws `NOT_IMPLEMENTED` (attributed to US-06-TASK-BE-01, which has not built its real body
|
|
71
|
+
yet). Calling `ai-toolkit executor status` today always fails with exit code 1 and an error
|
|
72
|
+
JSON body — it does not return the summary shape documented in its own JSDoc until that task is
|
|
73
|
+
implemented.
|
|
74
|
+
|
|
75
|
+
### `diagnose`
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
ai-toolkit executor diagnose --project <path> --run-id <uuid>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`--run-id` is required. Intended as a read-only detailed evidence dump (per-task/attempt
|
|
82
|
+
evidence, ownership/lease state, repair suggestions).
|
|
83
|
+
|
|
84
|
+
**Not implemented.** Same situation as `status`: `diagnose()` is a stub that always throws
|
|
85
|
+
`NOT_IMPLEMENTED` (attributed to US-06-TASK-BE-02). Calling it today always exits 1 with an
|
|
86
|
+
error JSON body.
|
|
87
|
+
|
|
88
|
+
### `stop`
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ai-toolkit executor stop --project <path> --run-id <uuid> [--mode graceful|immediate]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Flag | Required | Default | Notes |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `--run-id` | **yes** | — | |
|
|
97
|
+
| `--mode` | no | `'graceful'` | Only `'graceful'` or `'immediate'` are accepted; anything else throws `STOP_VALIDATION_ERROR` |
|
|
98
|
+
|
|
99
|
+
Persists a stop request and acknowledges receipt only — it never itself claims the run has
|
|
100
|
+
stopped.
|
|
101
|
+
|
|
102
|
+
- `graceful` (default): persists the request; the already-running `execute()` loop is the only
|
|
103
|
+
thing that actually enforces the block, at its next natural iteration boundary.
|
|
104
|
+
- `immediate`: additionally attempts real termination of every in-flight attempt (stage
|
|
105
|
+
`dispatching`/`running` with a recorded process tag), via the same Windows-only tagged-process
|
|
106
|
+
tree-kill technique used elsewhere in this module, then polls for confirmed termination.
|
|
107
|
+
|
|
108
|
+
Idempotent: if the run's current status is already `stopping` or settled
|
|
109
|
+
(`completed`/`blocked`/`superseded`/`paused`), this call is a pure no-op and reports the
|
|
110
|
+
originally-recorded mode.
|
|
111
|
+
|
|
112
|
+
### `reconcile`
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
ai-toolkit executor reconcile --run-id <uuid> [--project <path>]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`--run-id` is required. `--project` resolves the execution root and the current-branch task ref
|
|
119
|
+
automatically (via `git rev-parse --abbrev-ref HEAD` in the given `--project`, or its default,
|
|
120
|
+
the current directory) — there is no separate flag for these; the CLI derives them itself.
|
|
121
|
+
|
|
122
|
+
Reconciles persisted state and evidence for a run and classifies every task's current attempt.
|
|
123
|
+
**Only repairs proven gaps — it never dispatches new work and never creates new integrations.**
|
|
124
|
+
The only two repairs it can apply are: registering a commit SHA when a checkpoint-prepared
|
|
125
|
+
attempt's tip exactly matches its own persisted checkpoint intent, and finalizing a task
|
|
126
|
+
checkpoint when a committed attempt's registered SHA is confirmed reachable but the task's
|
|
127
|
+
status was not yet advanced. Refuses to proceed (`COMPETING_COORDINATOR_LIVE` /
|
|
128
|
+
`COMPETING_COORDINATOR_LIVENESS_UNKNOWN`) if a different, live-or-unknown-liveness coordinator
|
|
129
|
+
holds the repo-wide execution lease.
|
|
130
|
+
|
|
131
|
+
### `resume`
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
ai-toolkit executor resume --run-id <uuid> [--project <path>]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Same flags/derivation as `reconcile` (in fact the CLI dispatches both `reconcile` and `resume`
|
|
138
|
+
through the identical flag-parsing branch, selecting the target function by subcommand name).
|
|
139
|
+
Enforces the same "no live competing coordinator" precondition, then calls `reconcile()` to
|
|
140
|
+
apply its repairs, then reports each task's current status plus the next-safe-action
|
|
141
|
+
classification `reconcile()` already computed. Does **not** dispatch new work itself — there is
|
|
142
|
+
no run loop wired into `resume()` yet (the real dispatch loop lives in `execute()`); its scope
|
|
143
|
+
ends at "reconcile, then report what is safe to continue."
|
|
144
|
+
|
|
145
|
+
### `replan`
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
ai-toolkit executor replan --run-id <uuid> --feature <path> [--project <path>] [--task-mapping-json '<json>']
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Flag | Required | Default | Notes |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| `--run-id` | **yes** | — | The original run being replanned |
|
|
154
|
+
| `--feature` | **yes** | — | Path to the **successor** feature's `feature.md` (the approved successor plan) |
|
|
155
|
+
| `--project` | no | `process.cwd()` | Resolves execution root + current-branch task ref, same as `reconcile`/`resume` |
|
|
156
|
+
| `--task-mapping-json` | no | `'[]'` (empty array) | JSON array of `{oldTaskId, newTaskId}` objects |
|
|
157
|
+
|
|
158
|
+
**Discrepancy vs. Tech-Spec:** the table in section 10 lists only `--run-id`/`--feature` for
|
|
159
|
+
`replan`. The real `replan()` in `lib/task-executor/index.js` requires `args.taskMapping` to be
|
|
160
|
+
an array (throwing `DISPATCH_VALIDATION_ERROR` otherwise) and never invents one itself. The CLI
|
|
161
|
+
therefore added `--task-mapping-json` as a necessary extension beyond the literal table, using
|
|
162
|
+
the same `--*-json` structured-input convention the `ledger` subcommand already established
|
|
163
|
+
(`--metadata-json`). This is documented here as an intentional, flagged gap — not silently
|
|
164
|
+
papered over — per the implementing agent's own note at the flag's parse site in `bin/cli.js`.
|
|
165
|
+
Omitting the flag defaults to an empty mapping (a valid "no task carries forward" array), never
|
|
166
|
+
a rejection.
|
|
167
|
+
|
|
168
|
+
Grants **no approval itself** — the successor's Gate 2 approval must already exist on disk
|
|
169
|
+
(`## Gate 2` heading in its `Approvals.md`, produced entirely outside the executor by the normal
|
|
170
|
+
`implement-feature` gate protocol). Does not dispatch, start, or touch the successor run in any
|
|
171
|
+
way. Validates the caller-supplied old→new task mapping against both the original run's recorded
|
|
172
|
+
tasks and the successor's plan snapshot, computes carry-completion eligibility per pair
|
|
173
|
+
(read-only), refuses if any original-run attempt's worker liveness is not confirmed dead
|
|
174
|
+
(`LIVE_WORKER_BLOCKS_REPLAN`), then records the mapping as a durable intent and marks the
|
|
175
|
+
original run `superseded`. Never deletes or rewrites any task/attempt evidence.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Exit codes
|
|
180
|
+
|
|
181
|
+
| Exit code | Meaning |
|
|
182
|
+
|---|---|
|
|
183
|
+
| 0 | Successful operation/request |
|
|
184
|
+
| 1 | Unexpected I/O (also the default for any unmapped/unknown error code) |
|
|
185
|
+
| 2 | Invalid config/input |
|
|
186
|
+
| 3 | Ownership/live worker conflict |
|
|
187
|
+
| 4 | Evidence/plan/approval mismatch |
|
|
188
|
+
| 5 | Worker/verification/review failure |
|
|
189
|
+
| 6 | Integration conflict |
|
|
190
|
+
| 7 | Runtime not qualified |
|
|
191
|
+
| 8 | Paused awaiting human decision |
|
|
192
|
+
|
|
193
|
+
This table matches `bin/cli.js`'s `EXECUTOR_EXIT_CODE_BY_ERROR_CODE` mapping (plus a
|
|
194
|
+
suffix-based catch-all: any error code ending in `_VALIDATION_ERROR` maps to exit 2). A few
|
|
195
|
+
notable groupings from that mapping:
|
|
196
|
+
|
|
197
|
+
- **3** — every lease/ownership code: `LEASE_HELD`, `LEASE_NOT_OWNER`, `LEASE_STILL_LIVE`,
|
|
198
|
+
`LEASE_LIVENESS_UNKNOWN`, `LEASE_NOT_FOUND`, `GUARD_HELD`, `NO_LOCK_HELD`,
|
|
199
|
+
`COMPETING_COORDINATOR_LIVE`, `COMPETING_COORDINATOR_LIVENESS_UNKNOWN`,
|
|
200
|
+
`LIVE_WORKER_BLOCKS_REPLAN`.
|
|
201
|
+
- **4** — every "plan/evidence/approval doesn't match" code, including
|
|
202
|
+
`SUCCESSOR_NOT_APPROVED`, `PLAN_NOT_FOUND`, `PLAN_PARSE_ERROR`, `REPLAN_UNKNOWN_TASK_MAPPING`,
|
|
203
|
+
`STATE_NOT_FOUND`/`STATE_TASK_NOT_FOUND`/`STATE_ATTEMPT_NOT_FOUND`, `TREE_MISMATCH`,
|
|
204
|
+
`RECEIPT_CONFLICT`, `INTENT_CONFLICT`, and many more (see the source comment for the full
|
|
205
|
+
list).
|
|
206
|
+
- **5** — `CLAUDE_SPAWN_FAILED`, `AGENT_NOT_VERIFIED`, `RESOLUTION_FAILED`.
|
|
207
|
+
- **6** — `INTEGRATION_CONFLICT`.
|
|
208
|
+
- **7** — `PLATFORM_NOT_QUALIFIED` (see "Platform qualification" below).
|
|
209
|
+
- **1 (default)** — includes `NOT_IMPLEMENTED` explicitly, so `status`/`diagnose` calls exit 1
|
|
210
|
+
today, not some special code.
|
|
211
|
+
|
|
212
|
+
**Exit code 8 is a success-path outcome, never a thrown error** — unlike every other code
|
|
213
|
+
above, which is assigned only on the error path via `mapExecutorErrorToExitCode`. Per the
|
|
214
|
+
Tech-Spec, 8 means "paused awaiting human decision": `execute()`'s and `resume()`'s result
|
|
215
|
+
objects can both legitimately carry `runStatus: 'paused'` (set when a `stop()` request — either
|
|
216
|
+
mode — was observed at a loop boundary; `resume()` simply passes through whatever `runStatus`
|
|
217
|
+
was last persisted, including `'paused'`). `handleExecutorCommand`'s shared `emitSuccess()`
|
|
218
|
+
inspects `result.runStatus` and sets exit code **8** when it is `'paused'`, else **0** — this is
|
|
219
|
+
a no-op check for `reconcile`/`stop`/`replan` (their result shapes never carry a `runStatus`
|
|
220
|
+
field at all) and never applies to `status`/`diagnose` (still `NOT_IMPLEMENTED`, so they never
|
|
221
|
+
reach the success path).
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## JSON result schema
|
|
226
|
+
|
|
227
|
+
### Success (stdout, exit matches the table above)
|
|
228
|
+
|
|
229
|
+
Every command that has a real implementation returns a **versioned result object** —
|
|
230
|
+
`{ protocolVersion, ... }` — written to stdout as pretty-printed JSON. Per
|
|
231
|
+
`lib/task-executor/index.js`'s own JSDoc/return statements:
|
|
232
|
+
|
|
233
|
+
**`start` (`execute()`) and `resume`:**
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"protocolVersion": 1,
|
|
237
|
+
"runId": "<uuid>",
|
|
238
|
+
"runStatus": "completed | blocked | paused",
|
|
239
|
+
"tasks": [ { "taskId": "US-01-T01", "status": "checkpointed" } ]
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
`resume`'s object additionally carries `tasks[].nextAction` (the next-safe-action classification
|
|
243
|
+
from `reconcile()`) and a top-level `repairsApplied` array — both additive beyond the minimum
|
|
244
|
+
shape above.
|
|
245
|
+
|
|
246
|
+
**`stop`:**
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"protocolVersion": 1,
|
|
250
|
+
"runId": "<uuid>",
|
|
251
|
+
"requestAccepted": true,
|
|
252
|
+
"mode": "graceful | immediate",
|
|
253
|
+
"idempotent": true,
|
|
254
|
+
"terminationConfirmed": true,
|
|
255
|
+
"killAttempts": [
|
|
256
|
+
{ "taskId": "US-01-T01", "attemptNumber": 1, "processIdentity": "ai-toolkit-task:...", "confirmed": true }
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
`idempotent` is present only on an idempotent replay. `terminationConfirmed`/`killAttempts` are
|
|
261
|
+
present only for a newly-accepted `immediate` request — never for `graceful`, never for an
|
|
262
|
+
idempotent replay.
|
|
263
|
+
|
|
264
|
+
**`reconcile`:**
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"protocolVersion": 1,
|
|
268
|
+
"runId": "<uuid>",
|
|
269
|
+
"repairsApplied": [ { "taskId": "US-01-T01", "action": "registerCommitSHA" } ],
|
|
270
|
+
"classifications": [ { "taskId": "US-01-T01", "classification": "...", "action": "..." } ]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**`replan`:**
|
|
275
|
+
```json
|
|
276
|
+
{
|
|
277
|
+
"protocolVersion": 1,
|
|
278
|
+
"originalRunId": "<uuid>",
|
|
279
|
+
"successorPlanDigest": "<digest>",
|
|
280
|
+
"taskMapping": [
|
|
281
|
+
{ "oldTaskId": "US-01-T01", "newTaskId": "US-01-T01", "carriesCompletion": true }
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**`status`/`diagnose`:** no success shape exists yet — both are `NOT_IMPLEMENTED` stubs (see
|
|
287
|
+
above); every call surfaces the error shape below with `code: "NOT_IMPLEMENTED"`.
|
|
288
|
+
|
|
289
|
+
### Error (stderr, exit per the table above)
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"status": "error",
|
|
294
|
+
"code": "NO_LOCK_HELD",
|
|
295
|
+
"message": "dispatchTaskAttempt: no repo-wide execution lease held by runId \"...\" at ..."
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This is `bin/cli.js`'s real `emitError()` shape: `code` falls back to `'UNKNOWN'` if the thrown
|
|
300
|
+
error carries no `.code`; `message` falls back to `String(err)` if the error carries no
|
|
301
|
+
`.message`.
|
|
302
|
+
|
|
303
|
+
A malformed CLI invocation (e.g. missing a required flag) is a **usage error**, not one of the
|
|
304
|
+
JSON error shapes above: it writes a plain `Error: <message>` line to stderr and sets exit code 2
|
|
305
|
+
directly, without going through `mapExecutorErrorToExitCode`.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Platform qualification
|
|
310
|
+
|
|
311
|
+
`execute()` checks `process.platform !== 'win32'` as its very first validation step — before any
|
|
312
|
+
other argument validation, before deriving the execution root, before touching the plan
|
|
313
|
+
snapshot, and before acquiring the ownership lease — and throws `PLATFORM_NOT_QUALIFIED` (exit
|
|
314
|
+
7) on any non-Windows platform. This reflects FTR-018's Gate-1 binding constraint: the E-02
|
|
315
|
+
process-supervision evidence (tree-kill via `taskkill`, tag-based liveness scanning via
|
|
316
|
+
`Win32_Process` CIM queries) that the executor's crash-recovery/stop/worker-liveness machinery
|
|
317
|
+
depends on is qualified for Windows only (US-08-TASK-BE-05). There is no implicit
|
|
318
|
+
cross-platform guarantee — other platforms require their own dedicated qualification proof
|
|
319
|
+
before `start` will run on them at all.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Deterministic workflow control plane
|
|
2
|
+
|
|
3
|
+
The toolkit owns workflow execution in Node. An LLM is used only for document
|
|
4
|
+
generation, review, and assessment. A workflow script is not a registered worker
|
|
5
|
+
agent: never pass `pm-phase1`, `pm-phase2`, `pm-phase3`, `am-phase1`, or `am-phase2`
|
|
6
|
+
to `agentType`, `subagent_type`, or Claude's `--agent`.
|
|
7
|
+
|
|
8
|
+
## Invocation
|
|
9
|
+
|
|
10
|
+
Reinstall the toolkit runtime assets after upgrading the package. The host rejects
|
|
11
|
+
older installed workflows without `controlPlaneVersion: 1`, even if their old
|
|
12
|
+
manifest hashes match. Use one installation scope (local or global); ambiguous or
|
|
13
|
+
unverified agent installations fail closed.
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
ai-toolkit workflow run pm-phase1 --project . --claude-path "C:\path\claude.exe" -- "internal_docs/features/FTR-123-example/feature.md"
|
|
17
|
+
ai-toolkit workflow run pm-phase2 --project . --claude-path "C:\path\claude.exe" -- "internal_docs/features/FTR-123-example/feature.md"
|
|
18
|
+
ai-toolkit workflow run am-phase1 --project . --claude-path "C:\path\claude.exe" -- . --scope=security,devops --prefix ASSESS-123
|
|
19
|
+
ai-toolkit workflow run am-phase2 --project . -- --prefix ASSESS-123 --flagged INT-001 --ack "Reviewed by the user"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Resolve the actual Claude executable as described in `task-executor-bootstrap.md`.
|
|
23
|
+
`--home DIR` is available for explicit installation-scope resolution. Paths are
|
|
24
|
+
individual argv elements, never interpolated shell commands. Raw string arguments
|
|
25
|
+
in the JavaScript API support quoted paths and preserve Windows backslashes.
|
|
26
|
+
|
|
27
|
+
Exit 0 returns a JSON payload on stdout. A nonzero exit stops the pipeline; do not
|
|
28
|
+
present the next approval gate. Gate 1/Gate 2 and Findings Gate decisions remain in
|
|
29
|
+
the main loop. `am-phase2` requires explicit acknowledgement and a validated
|
|
30
|
+
intervention selection; it does not invent approval.
|
|
31
|
+
|
|
32
|
+
`pm-phase3` is a retired compatibility entry point and refuses execution. Since
|
|
33
|
+
FTR-018, implementation belongs to `ai-toolkit tasks start/run`, with its task
|
|
34
|
+
ownership, checkpoints, verification, review and resume protocol. The old workflow
|
|
35
|
+
must not provide a fallback execution path. No JobScheduler feature is involved
|
|
36
|
+
in this migration.
|
|
37
|
+
|
|
38
|
+
## Audit and replacements
|
|
39
|
+
|
|
40
|
+
| Original location | Deterministic operation formerly delegated to `agent()` | Replacement |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| pm-phase1 | agent resolution; self/worker ledger open/close | Verified host dispatch and owned activity lifecycle |
|
|
43
|
+
| pm-phase1 | feature discovery, prefix, existence/mtime checks, ledger creation | Direct filesystem operations; freshness includes upstream documents; corrupt ledgers are preserved and rejected |
|
|
44
|
+
| pm-phase1 | validation report existence/fabrication, process log writes | Require a nonempty fresh report from the validator; append log directly; structured validation verdict |
|
|
45
|
+
| pm-phase2 | resolution and ledger operations | Same host lifecycle; unsuccessful results cannot close the owner as done |
|
|
46
|
+
| pm-phase2 | validator/renderer execution, exit/output parsing and file checks | Toolkit CLI subprocess, `shell: false`, argv arrays, actual exit status, JSON parsing and disk reads |
|
|
47
|
+
| pm-phase2 | raw pricing read, WB metrics, Token/Effort estimates, process log | Direct JSON reads and deterministic templates; unavailable usage/cost stays unavailable |
|
|
48
|
+
| pm-phase3 | resolution, all ledger mutations, raw CSV/ledger/pricing reads, issue counts, test/git/PR commands, actuals writes | Legacy execution removed; implementation and CSV parsing already belong to the FTR-018 executor |
|
|
49
|
+
| am-phase1 | discovery CLI, catalog/file reads, scope filtering, estimates/index counts | Canonical assessor map, verified resolution, strict scope filtering and deterministic Markdown parsing/templates |
|
|
50
|
+
| am-phase2 | approval file, intervention selection, registry read/write/counts | Deterministic recorder with explicit input and readback |
|
|
51
|
+
| FTR-018 process helper | worker identity cache and dispatch identity | Reject orchestrators; require requested canonical ID/native name agreement; cache by project + home + ID |
|
|
52
|
+
|
|
53
|
+
Architecture, concurrency and quality have dedicated assessors. Security, DevOps,
|
|
54
|
+
domain-model and dependencies use the generic assessor with explicit scope
|
|
55
|
+
instructions. Multiple generic scopes are combined into one dispatch to avoid
|
|
56
|
+
parallel writers overwriting the same report. Remediation agents are recommendations
|
|
57
|
+
in intervention documents only; they are never used as assessors.
|
|
58
|
+
|
|
59
|
+
## Identity, state and ownership
|
|
60
|
+
|
|
61
|
+
Worker resolution checks the canonical catalog ID, verified status, exact native
|
|
62
|
+
name, current definition hash, agent frontmatter name/model, and worker kind.
|
|
63
|
+
Workflow resolution is separate and can never authorize worker dispatch.
|
|
64
|
+
|
|
65
|
+
Every invocation has a unique owner and activity IDs. A per-prefix lock prevents
|
|
66
|
+
two workflow runs from writing the same feature/assessment concurrently. The owner
|
|
67
|
+
opens before work, drains parallel workers, validates outputs and serializes the
|
|
68
|
+
result before its terminal transition. Worker usage and provenance are persisted.
|
|
69
|
+
Legacy `open/close/fail/skip/finalizeActivity` calls cannot mutate owned entries;
|
|
70
|
+
owned finalization requires the matching owner and a running entry. No name-only
|
|
71
|
+
fallback is used for owned transitions. Later invocations cannot reopen an old run.
|
|
72
|
+
|
|
73
|
+
The ownership token prevents accidental cross-finalization, not hostile filesystem
|
|
74
|
+
access: workers and the host still run under the user's OS account. This is not a
|
|
75
|
+
security sandbox.
|
|
76
|
+
|
|
77
|
+
A caught failure produces `failed`. A hard OS/process kill can leave `running`,
|
|
78
|
+
never a speculative `done`; workflow automatic resume/reconciliation is not added
|
|
79
|
+
by this change. Retrying uses a new owner and preserves the interrupted record.
|
|
80
|
+
If persistence itself fails, the error propagates rather than reporting success.
|
|
81
|
+
|
|
82
|
+
## Verification and limits
|
|
83
|
+
|
|
84
|
+
Regression tests execute the installed workflow source through the real host,
|
|
85
|
+
resolver, filesystem and ledger. Integration tests invoke the actual structural
|
|
86
|
+
validator/renderer and workflow CLI; LLM workers are deterministic test doubles.
|
|
87
|
+
Coverage includes worker failure, missing outputs, corrupt ledger, late terminal
|
|
88
|
+
calls, parallel rejection/drain, identity mismatch, cross-project cache leakage,
|
|
89
|
+
negative semantic verdict, unknown scopes, paths with spaces and explicit approval.
|
|
90
|
+
|
|
91
|
+
The live paid Claude model/permission flow has not been exercised by these tests.
|
|
92
|
+
Document validation requires both a written Validation Report and a structured
|
|
93
|
+
verdict (`valid: boolean`, `findings: string[]`). The validator performs one pass;
|
|
94
|
+
the host owns revisions. A report or JSON in the plain-text result cannot replace
|
|
95
|
+
`structured_output` from `--json-schema`. Missing structured output stops the
|
|
96
|
+
workflow with an error naming the worker and required fields. Regression tests
|
|
97
|
+
cover missing/malformed verdicts, a clean verdict without a report, and a negative
|
|
98
|
+
verdict followed by host revision and a clean result; model responses are mocked.
|
|
99
|
+
The subprocess adapter reuses FTR-018's existing qualification; no new cross-platform
|
|
100
|
+
process-supervision guarantee is claimed. On Windows, the full executor test suite
|
|
101
|
+
requires Git Bash ahead of the WSL launcher in PATH. User-level installations are
|
|
102
|
+
not modified by running the isolated test fixtures.
|
package/lib/execution-ledger.js
CHANGED
|
@@ -33,7 +33,8 @@ function _readLedger(ledgerPath) {
|
|
|
33
33
|
}
|
|
34
34
|
throw parseErr;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
if (!Array.isArray(parsed)) throw new Error('Invalid ledger: expected JSON array');
|
|
37
|
+
return { entries: parsed };
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function _writeLedger(ledgerPath, entries) {
|
|
@@ -340,6 +341,7 @@ function open(dir, prefix, agent, phase, model, attempt, metadata) {
|
|
|
340
341
|
});
|
|
341
342
|
|
|
342
343
|
if (existingIndex !== -1) {
|
|
344
|
+
_assertUnowned(entries[existingIndex]);
|
|
343
345
|
// Idempotent re-open: check metadata conflict BEFORE writing anything.
|
|
344
346
|
if (metadata != null) {
|
|
345
347
|
if (!_metadataEqual(entries[existingIndex], metadata)) {
|
|
@@ -422,7 +424,8 @@ function close(dir, prefix, agent, tokens, attempt) {
|
|
|
422
424
|
idx = agentIndexes[0];
|
|
423
425
|
}
|
|
424
426
|
|
|
425
|
-
const existing
|
|
427
|
+
const existing = entries[idx];
|
|
428
|
+
_assertUnowned(existing);
|
|
426
429
|
const completed_at = new Date().toISOString();
|
|
427
430
|
|
|
428
431
|
// Token preservation (AC-10):
|
|
@@ -496,7 +499,8 @@ function fail(dir, prefix, agent, error, attempt) {
|
|
|
496
499
|
idx = agentIndexes[0];
|
|
497
500
|
}
|
|
498
501
|
|
|
499
|
-
const existing
|
|
502
|
+
const existing = entries[idx];
|
|
503
|
+
_assertUnowned(existing);
|
|
500
504
|
const completed_at = new Date().toISOString();
|
|
501
505
|
|
|
502
506
|
const update = {
|
|
@@ -572,7 +576,8 @@ function skip(dir, prefix, agent, phase, model, attempt) {
|
|
|
572
576
|
}
|
|
573
577
|
|
|
574
578
|
// Case 2: EXACTLY ONE match — update in place, preserving started_at and all other fields.
|
|
575
|
-
const existing
|
|
579
|
+
const existing = entries[idx];
|
|
580
|
+
_assertUnowned(existing);
|
|
576
581
|
const completed_at = new Date().toISOString();
|
|
577
582
|
|
|
578
583
|
entries[idx] = Object.assign({}, existing, {
|
|
@@ -589,11 +594,188 @@ function skip(dir, prefix, agent, phase, model, attempt) {
|
|
|
589
594
|
}
|
|
590
595
|
}
|
|
591
596
|
|
|
597
|
+
// ── finalizeActivity (INFRA-TASK-BE-02) ──────────────────────────────────────
|
|
598
|
+
//
|
|
599
|
+
// Additive terminal-state API used by the executor. Unlike close()/fail()/skip(),
|
|
600
|
+
// it requires an exact operation_id match (no name-only agent fallback), can
|
|
601
|
+
// record tokens on ANY terminal status (including 'failed'), accepts an optional
|
|
602
|
+
// usage_reason (additive ledger field, NOT an open() metadata whitelist key),
|
|
603
|
+
// and is replay-safe: finalizing an already-terminal entry with identical data
|
|
604
|
+
// is a no-op that returns the existing entry unchanged, while conflicting data
|
|
605
|
+
// fails closed without writing. It does not introduce a second ledger file or
|
|
606
|
+
// a duplicate token counter — it reuses phase_delta_tokens like close()/fail().
|
|
607
|
+
|
|
608
|
+
const FINALIZE_STATUSES = new Set(['done', 'failed', 'skipped']);
|
|
609
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'skipped']);
|
|
610
|
+
|
|
611
|
+
function finalizeActivity(dir, prefix, agent, attempt, options) {
|
|
612
|
+
if (attempt == null) attempt = 1;
|
|
613
|
+
options = options || {};
|
|
614
|
+
|
|
615
|
+
const status = options.status;
|
|
616
|
+
const tokens = (options.tokens === undefined) ? null : options.tokens;
|
|
617
|
+
const reason = options.reason;
|
|
618
|
+
const completedAtArg = options.completedAt;
|
|
619
|
+
|
|
620
|
+
if (!FINALIZE_STATUSES.has(status)) {
|
|
621
|
+
const err = new Error(
|
|
622
|
+
'finalizeActivity: status must be one of "done", "failed", "skipped"; got ' +
|
|
623
|
+
JSON.stringify(status)
|
|
624
|
+
);
|
|
625
|
+
err.code = 'INVALID_STATUS';
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
|
|
630
|
+
const lockPath = ledgerPath + '.lock';
|
|
631
|
+
|
|
632
|
+
const lockHandle = _acquireLock(lockPath);
|
|
633
|
+
try {
|
|
634
|
+
const { entries } = _readLedger(ledgerPath);
|
|
635
|
+
const operation_id = computeOperationId(prefix, agent, attempt);
|
|
636
|
+
|
|
637
|
+
// Exact-ID lookup only — no name-only agent fallback (unlike close/fail/skip).
|
|
638
|
+
const idx = entries.findIndex(function (e) {
|
|
639
|
+
return e.operation_id === operation_id;
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
if (idx === -1) {
|
|
643
|
+
const err = new Error(
|
|
644
|
+
'finalizeActivity: no entry found for operation_id ' + operation_id +
|
|
645
|
+
' in ' + ledgerPath + ' — exact operation_id match required, ' +
|
|
646
|
+
'no name-only fallback; operation was never opened'
|
|
647
|
+
);
|
|
648
|
+
err.code = 'ACTIVITY_NOT_FOUND';
|
|
649
|
+
err.operation_id = operation_id;
|
|
650
|
+
throw err;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const existing = entries[idx];
|
|
654
|
+
_assertUnowned(existing);
|
|
655
|
+
|
|
656
|
+
const suppliedTokens = (tokens === undefined) ? null : tokens;
|
|
657
|
+
const suppliedReason = (reason === undefined) ? null : reason;
|
|
658
|
+
|
|
659
|
+
if (TERMINAL_STATUSES.has(existing.status)) {
|
|
660
|
+
// Already finalized: this is either an idempotent replay of identical
|
|
661
|
+
// data (return unchanged, no write) or conflicting data (fail closed).
|
|
662
|
+
const existingTokens = (existing.phase_delta_tokens === undefined)
|
|
663
|
+
? null : existing.phase_delta_tokens;
|
|
664
|
+
const existingReason = (existing.usage_reason === undefined)
|
|
665
|
+
? null : existing.usage_reason;
|
|
666
|
+
const effectiveCompletedAt = (completedAtArg != null)
|
|
667
|
+
? completedAtArg : existing.completed_at;
|
|
668
|
+
|
|
669
|
+
const isIdentical =
|
|
670
|
+
existing.status === status &&
|
|
671
|
+
existingTokens === suppliedTokens &&
|
|
672
|
+
existingReason === suppliedReason &&
|
|
673
|
+
existing.completed_at === effectiveCompletedAt;
|
|
674
|
+
|
|
675
|
+
if (isIdentical) {
|
|
676
|
+
return { status: 'ok', operation_id: operation_id, entry: existing, replay: true };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const err = new Error(
|
|
680
|
+
'finalizeActivity: conflicting terminal data for operation_id ' + operation_id +
|
|
681
|
+
' in ' + ledgerPath + ' — existing terminal entry differs from the supplied ' +
|
|
682
|
+
'status/tokens/reason/completedAt; refusing to overwrite (fail-closed)'
|
|
683
|
+
);
|
|
684
|
+
err.code = 'TERMINAL_CONFLICT';
|
|
685
|
+
err.operation_id = operation_id;
|
|
686
|
+
throw err;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Not yet terminal: finalize it. Tokens are recorded even when status is
|
|
690
|
+
// 'failed' — first finalization records known tokens, or null plus an
|
|
691
|
+
// optional usage_reason explaining why usage is unavailable. Matching the
|
|
692
|
+
// close() token-preservation invariant: a null/undefined supplied value
|
|
693
|
+
// never clobbers an already-stored positive phase_delta_tokens.
|
|
694
|
+
const completed_at = (completedAtArg != null) ? completedAtArg : new Date().toISOString();
|
|
695
|
+
|
|
696
|
+
let phase_delta_tokens = existing.phase_delta_tokens;
|
|
697
|
+
if (suppliedTokens !== null) {
|
|
698
|
+
phase_delta_tokens = suppliedTokens;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const update = {
|
|
702
|
+
status: status,
|
|
703
|
+
completed_at: completed_at,
|
|
704
|
+
phase_delta_tokens: phase_delta_tokens,
|
|
705
|
+
};
|
|
706
|
+
if (reason !== undefined) {
|
|
707
|
+
update.usage_reason = reason;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Merge update: Object.assign preserves unknown/legacy fields and metadata verbatim.
|
|
711
|
+
entries[idx] = Object.assign({}, existing, update);
|
|
712
|
+
|
|
713
|
+
_writeLedger(ledgerPath, entries);
|
|
714
|
+
|
|
715
|
+
return { status: 'ok', operation_id: operation_id, entry: entries[idx] };
|
|
716
|
+
} finally {
|
|
717
|
+
_releaseLock(lockPath, lockHandle);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Workflow-owned entries cannot be finalized by worker/legacy CLI calls.
|
|
722
|
+
function _assertUnowned(entry) {
|
|
723
|
+
if (entry && entry.workflow_owner) {
|
|
724
|
+
const error = new Error('Workflow entry requires its owner capability');
|
|
725
|
+
error.code = 'LEDGER_OWNER_REQUIRED';
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function openOwned(dir, prefix, agent, phase, model, owner, metadata) {
|
|
731
|
+
if (typeof owner !== 'string' || !owner) throw new Error('Workflow owner required');
|
|
732
|
+
if (metadata) _validateMetadata(metadata);
|
|
733
|
+
const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
|
|
734
|
+
const lockPath = ledgerPath + '.lock';
|
|
735
|
+
const handle = _acquireLock(lockPath);
|
|
736
|
+
try {
|
|
737
|
+
const { entries } = _readLedger(ledgerPath);
|
|
738
|
+
const operation_id = computeOperationId(prefix, agent, 1);
|
|
739
|
+
if (entries.some(e => e.operation_id === operation_id)) throw new Error('Owned operation already opened');
|
|
740
|
+
const entry = { operation_id, agent, phase, model, workflow_owner: owner,
|
|
741
|
+
status: 'running', started_at: new Date().toISOString(), completed_at: null, phase_delta_tokens: null };
|
|
742
|
+
if (metadata) Object.assign(entry, metadata);
|
|
743
|
+
entries.push(entry);
|
|
744
|
+
_writeLedger(ledgerPath, entries);
|
|
745
|
+
return entry;
|
|
746
|
+
} finally { _releaseLock(lockPath, handle); }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function finalizeOwned(dir, prefix, agent, owner, status, error, tokens) {
|
|
750
|
+
if (!['done', 'failed', 'skipped'].includes(status)) throw new Error('Invalid owned terminal status');
|
|
751
|
+
const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
|
|
752
|
+
const lockPath = ledgerPath + '.lock';
|
|
753
|
+
const handle = _acquireLock(lockPath);
|
|
754
|
+
try {
|
|
755
|
+
const { entries } = _readLedger(ledgerPath);
|
|
756
|
+
const id = computeOperationId(prefix, agent, 1);
|
|
757
|
+
const entry = entries.find(e => e.operation_id === id);
|
|
758
|
+
if (!entry || !owner || entry.workflow_owner !== owner) throw new Error('Ledger ownership mismatch');
|
|
759
|
+
if (entry.status !== 'running') throw new Error('Owned terminal transition conflict');
|
|
760
|
+
if (tokens != null && (!Number.isInteger(tokens) || tokens <= 0)) throw new Error('Invalid owned token measurement');
|
|
761
|
+
if (tokens != null) entry.phase_delta_tokens = tokens;
|
|
762
|
+
entry.status = status;
|
|
763
|
+
entry.completed_at = new Date().toISOString();
|
|
764
|
+
if (error) entry.error = _sanitizeError(error);
|
|
765
|
+
_writeLedger(ledgerPath, entries);
|
|
766
|
+
return entry;
|
|
767
|
+
} finally { _releaseLock(lockPath, handle); }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
592
771
|
module.exports = {
|
|
772
|
+
openOwned,
|
|
773
|
+
finalizeOwned,
|
|
593
774
|
open,
|
|
594
775
|
close,
|
|
595
776
|
fail,
|
|
596
777
|
skip,
|
|
778
|
+
finalizeActivity,
|
|
597
779
|
computeOperationId,
|
|
598
780
|
_readLedger,
|
|
599
781
|
_writeLedger,
|