@dtlabs/ai-toolkit 0.11.0 → 0.12.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.
@@ -0,0 +1,515 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+
8
+ // ── Internal I/O helpers (stubs — implemented by INFRA-TASK-BE-03) ──────────
9
+
10
+ function _readLedger(ledgerPath) {
11
+ let raw;
12
+ try {
13
+ raw = fs.readFileSync(ledgerPath, 'utf8');
14
+ } catch (err) {
15
+ if (err.code === 'ENOENT') {
16
+ return { entries: [] };
17
+ }
18
+ throw err;
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(raw);
23
+ } catch (parseErr) {
24
+ if (raw.length > 0) {
25
+ _backupCorruptFile(ledgerPath);
26
+ const err = new Error(
27
+ 'corrupt ledger at ' + ledgerPath + ': ' + parseErr.message +
28
+ ' — original preserved unchanged, corrupt content backed up to sidecar'
29
+ );
30
+ err.code = 'CORRUPT_LEDGER';
31
+ err.ledgerPath = ledgerPath;
32
+ throw err;
33
+ }
34
+ throw parseErr;
35
+ }
36
+ return { entries: Array.isArray(parsed) ? parsed : [] };
37
+ }
38
+
39
+ function _writeLedger(ledgerPath, entries) {
40
+ const dir = path.dirname(ledgerPath);
41
+ const tmpName = '.' + path.basename(ledgerPath) + '.tmp-' + process.pid + '-' + Date.now();
42
+ const tmpPath = path.join(dir, tmpName);
43
+ const jsonStr = JSON.stringify(entries, null, 2);
44
+
45
+ let fd;
46
+ try {
47
+ fd = fs.openSync(tmpPath, 'w');
48
+ fs.writeSync(fd, jsonStr);
49
+ fs.fsyncSync(fd);
50
+ fs.closeSync(fd);
51
+ fd = undefined;
52
+ } catch (err) {
53
+ if (fd !== undefined) {
54
+ try { fs.closeSync(fd); } catch (_) {}
55
+ }
56
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
57
+ throw err;
58
+ }
59
+
60
+ try {
61
+ fs.renameSync(tmpPath, ledgerPath);
62
+ } catch (err) {
63
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
64
+ try { fs.unlinkSync(ledgerPath); } catch (_) {}
65
+ fs.renameSync(tmpPath, ledgerPath);
66
+ } else {
67
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
68
+ throw err;
69
+ }
70
+ }
71
+ }
72
+
73
+ function _backupCorruptFile(ledgerPath) {
74
+ const dir = path.dirname(ledgerPath);
75
+ const base = path.basename(ledgerPath);
76
+ const sidecarName = base + '.backup-corrupt-' + Date.now();
77
+ const sidecarPath = path.join(dir, sidecarName);
78
+ const content = fs.readFileSync(ledgerPath);
79
+ fs.writeFileSync(sidecarPath, content);
80
+ }
81
+
82
+ // ── Internal lock helpers ────────────────────────────────────────────────────
83
+
84
+ function _sleepSync(ms) {
85
+ const end = Date.now() + ms;
86
+ while (Date.now() < end) { /* busy-wait for synchronous cross-process backoff */ }
87
+ }
88
+
89
+ function _acquireLock(lockPath, opts) {
90
+ const deadlineMs = (opts && opts.deadlineMs != null) ? opts.deadlineMs : 5000;
91
+ const retryIntervalMs = (opts && opts.retryIntervalMs != null) ? opts.retryIntervalMs : 100;
92
+ const deadline = Date.now() + deadlineMs;
93
+
94
+ for (;;) {
95
+ let fd;
96
+ try {
97
+ fd = fs.openSync(lockPath, 'wx');
98
+ const nonce = crypto.randomBytes(16).toString('hex');
99
+ const startedAt = new Date().toISOString();
100
+ const ownerToken = { pid: process.pid, startedAt, nonce };
101
+ fs.writeSync(fd, JSON.stringify(ownerToken));
102
+ fs.fsyncSync(fd);
103
+ fs.closeSync(fd);
104
+ return { nonce, lockPath, pid: process.pid, startedAt };
105
+ } catch (err) {
106
+ if (fd !== undefined) {
107
+ try { fs.closeSync(fd); } catch (_) {}
108
+ if (err.code !== 'EEXIST') {
109
+ try { fs.unlinkSync(lockPath); } catch (_) {}
110
+ }
111
+ }
112
+ if (err.code !== 'EEXIST') {
113
+ throw err;
114
+ }
115
+
116
+ // ABA-guarded staleness check + reclaim (US-10 / AC-08 / AC-26)
117
+ try {
118
+ let content1, stat1;
119
+ try {
120
+ content1 = fs.readFileSync(lockPath, 'utf8');
121
+ stat1 = fs.statSync(lockPath);
122
+ } catch (readErr) {
123
+ if (readErr.code === 'ENOENT') {
124
+ continue; // lock vanished between EEXIST and our read — retry O_EXCL immediately
125
+ }
126
+ throw readErr;
127
+ }
128
+
129
+ const state = _isLockStale(content1, stat1);
130
+
131
+ if (state === 'reclaimable') {
132
+ // ABA guard: re-read and re-stat before unlinking to ensure no other
133
+ // writer replaced or refreshed the lock between classify and reclaim.
134
+ let content2, stat2;
135
+ try {
136
+ content2 = fs.readFileSync(lockPath, 'utf8');
137
+ stat2 = fs.statSync(lockPath);
138
+ } catch (abaErr) {
139
+ if (abaErr.code === 'ENOENT') {
140
+ continue; // lock vanished during ABA check — retry O_EXCL immediately
141
+ }
142
+ throw abaErr;
143
+ }
144
+
145
+ if (content2 === content1 && stat2.mtimeMs === stat1.mtimeMs) {
146
+ // Content and mtime are identical — safe to unlink and reclaim
147
+ try {
148
+ fs.unlinkSync(lockPath);
149
+ } catch (unlinkErr) {
150
+ if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
151
+ // ENOENT: another process reclaimed it first — still retry O_EXCL
152
+ }
153
+ continue; // retry O_EXCL create immediately, no sleep
154
+ }
155
+ // ABA check failed: content or mtime changed — another writer refreshed
156
+ // the lock between classify and reclaim; treat as 'wait' this iteration
157
+ }
158
+ // 'live', 'wait', or ABA-failed 'reclaimable': fall through to deadline + sleep
159
+ } catch (checkErr) {
160
+ if (checkErr.code === 'ENOENT') {
161
+ continue; // lock vanished during stale check — retry O_EXCL immediately
162
+ }
163
+ // Any other unexpected error during the staleness check: absorb and fall
164
+ // through to the normal wait/sleep path so acquisition is never aborted
165
+ // by a transient read error.
166
+ }
167
+
168
+ // Existing behavior for live / wait / ABA-failed locks
169
+ if (Date.now() >= deadline) {
170
+ throw new Error(
171
+ 'Failed to acquire lock ' + lockPath + ': deadline of ' + deadlineMs + 'ms exceeded'
172
+ );
173
+ }
174
+ const remaining = deadline - Date.now();
175
+ if (remaining > 0) {
176
+ _sleepSync(Math.min(retryIntervalMs, remaining));
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ function _releaseLock(lockPath, handle) {
183
+ let content;
184
+ try {
185
+ content = fs.readFileSync(lockPath, 'utf8');
186
+ } catch (_) {
187
+ return;
188
+ }
189
+ let ownerToken;
190
+ try {
191
+ ownerToken = JSON.parse(content);
192
+ } catch (_) {
193
+ return;
194
+ }
195
+ if (!ownerToken || ownerToken.nonce !== handle.nonce) {
196
+ return;
197
+ }
198
+ try {
199
+ fs.unlinkSync(lockPath);
200
+ } catch (_) {}
201
+ }
202
+
203
+ const LOCK_STALE_THRESHOLD_MS = 30000;
204
+ const LOCK_ORPHAN_THRESHOLD_MS = 30000;
205
+
206
+ function _isLockStale(content, stat) {
207
+ let token = null;
208
+ try {
209
+ const parsed = JSON.parse(content);
210
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.pid === 'number') {
211
+ token = parsed;
212
+ }
213
+ } catch (_) {}
214
+
215
+ const now = Date.now();
216
+
217
+ if (token !== null) {
218
+ let ownerAlive = false;
219
+ try {
220
+ process.kill(token.pid, 0);
221
+ ownerAlive = true;
222
+ } catch (err) {
223
+ if (err.code === 'EPERM') {
224
+ ownerAlive = true;
225
+ }
226
+ }
227
+
228
+ if (ownerAlive) {
229
+ return 'live';
230
+ }
231
+
232
+ let age;
233
+ const startedAtMs = token.startedAt ? Date.parse(token.startedAt) : NaN;
234
+ if (!isNaN(startedAtMs)) {
235
+ age = now - startedAtMs;
236
+ } else {
237
+ age = now - stat.mtimeMs;
238
+ }
239
+
240
+ return age > LOCK_STALE_THRESHOLD_MS ? 'reclaimable' : 'wait';
241
+ }
242
+
243
+ const mtimeAge = now - stat.mtimeMs;
244
+ return mtimeAge > LOCK_ORPHAN_THRESHOLD_MS ? 'reclaimable' : 'wait';
245
+ }
246
+
247
+ // ── Public API (stubs — implemented by INFRA-TASK-BE-02 and US-0x tasks) ────
248
+
249
+ function computeOperationId(prefix, agent, attempt) {
250
+ const input = JSON.stringify([prefix, agent, attempt]);
251
+ return crypto.createHash('sha256').update(input, 'utf8').digest('hex').slice(0, 32);
252
+ }
253
+
254
+ function open(dir, prefix, agent, phase, model, attempt) {
255
+ if (attempt == null) attempt = 1;
256
+
257
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
258
+ const lockPath = ledgerPath + '.lock';
259
+
260
+ const lockHandle = _acquireLock(lockPath);
261
+ try {
262
+ const { entries } = _readLedger(ledgerPath);
263
+ const operation_id = computeOperationId(prefix, agent, attempt);
264
+ const started_at = new Date().toISOString();
265
+
266
+ const existingIndex = entries.findIndex(function (e) {
267
+ return e.operation_id === operation_id;
268
+ });
269
+
270
+ if (existingIndex !== -1) {
271
+ // Resume: idempotently preserve original started_at and any positive
272
+ // phase_delta_tokens — only reset status to running.
273
+ entries[existingIndex] = Object.assign({}, entries[existingIndex], { status: 'running' });
274
+ } else {
275
+ entries.push({
276
+ operation_id: operation_id,
277
+ agent: agent,
278
+ phase: phase,
279
+ model: model,
280
+ status: 'running',
281
+ started_at: started_at,
282
+ completed_at: null,
283
+ phase_delta_tokens: null,
284
+ });
285
+ }
286
+
287
+ _writeLedger(ledgerPath, entries);
288
+
289
+ const resultEntry = existingIndex !== -1
290
+ ? entries[existingIndex]
291
+ : entries[entries.length - 1];
292
+
293
+ return { status: 'ok', operation_id: operation_id, entry: resultEntry };
294
+ } finally {
295
+ _releaseLock(lockPath, lockHandle);
296
+ }
297
+ }
298
+
299
+ function close(dir, prefix, agent, tokens, attempt) {
300
+ if (attempt == null) attempt = 1;
301
+
302
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
303
+ const lockPath = ledgerPath + '.lock';
304
+
305
+ const lockHandle = _acquireLock(lockPath);
306
+ try {
307
+ const { entries } = _readLedger(ledgerPath);
308
+ const operation_id = computeOperationId(prefix, agent, attempt);
309
+
310
+ // Primary lookup: by operation_id (deterministic, unambiguous)
311
+ let idx = entries.findIndex(function (e) {
312
+ return e.operation_id === operation_id;
313
+ });
314
+
315
+ // Fallback: by agent name — only when exactly one entry matches (AC-12)
316
+ if (idx === -1) {
317
+ const agentIndexes = [];
318
+ for (var i = 0; i < entries.length; i++) {
319
+ if (entries[i].agent === agent) agentIndexes.push(i);
320
+ }
321
+ if (agentIndexes.length === 0) {
322
+ throw new Error(
323
+ 'close: no entry found for operation_id ' + operation_id +
324
+ ' and agent "' + agent + '" in ' + ledgerPath
325
+ );
326
+ }
327
+ if (agentIndexes.length > 1) {
328
+ throw new Error(
329
+ 'close: ambiguous agent fallback — ' + agentIndexes.length +
330
+ ' entries match agent "' + agent + '" in ' + ledgerPath
331
+ );
332
+ }
333
+ idx = agentIndexes[0];
334
+ }
335
+
336
+ const existing = entries[idx];
337
+ const completed_at = new Date().toISOString();
338
+
339
+ // Token preservation (AC-10):
340
+ // - positive integer supplied → record it
341
+ // - null/undefined supplied → preserve whatever positive value is already stored;
342
+ // null must never clobber an existing positive phase_delta_tokens
343
+ var phase_delta_tokens = existing.phase_delta_tokens;
344
+ if (tokens !== null && tokens !== undefined) {
345
+ phase_delta_tokens = tokens;
346
+ }
347
+ // When tokens is null/undefined, phase_delta_tokens keeps its existing value
348
+ // (positive preserved; null/non-positive also left unchanged for this task)
349
+
350
+ // Merge update: Object.assign preserves any unknown/legacy fields verbatim (AC-12)
351
+ entries[idx] = Object.assign({}, existing, {
352
+ status: 'done',
353
+ completed_at: completed_at,
354
+ phase_delta_tokens: phase_delta_tokens,
355
+ });
356
+
357
+ _writeLedger(ledgerPath, entries);
358
+
359
+ // Return same shape as open() — operation_id comes from the entry when present,
360
+ // otherwise from the computed value (legacy entry without one, per AC-12)
361
+ const returnedOpId = existing.operation_id != null ? existing.operation_id : operation_id;
362
+ return { status: 'ok', operation_id: returnedOpId, entry: entries[idx] };
363
+ } finally {
364
+ _releaseLock(lockPath, lockHandle);
365
+ }
366
+ }
367
+
368
+ function _sanitizeError(err) {
369
+ return String(err).replace(/[\r\n\t]+/g, ' ').slice(0, 500);
370
+ }
371
+
372
+ function fail(dir, prefix, agent, error, attempt) {
373
+ if (attempt == null) attempt = 1;
374
+
375
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
376
+ const lockPath = ledgerPath + '.lock';
377
+
378
+ const lockHandle = _acquireLock(lockPath);
379
+ try {
380
+ const { entries } = _readLedger(ledgerPath);
381
+ const operation_id = computeOperationId(prefix, agent, attempt);
382
+
383
+ // Primary lookup: by operation_id (deterministic, unambiguous)
384
+ let idx = entries.findIndex(function (e) {
385
+ return e.operation_id === operation_id;
386
+ });
387
+
388
+ // Fallback: by agent name — only when exactly one entry matches
389
+ if (idx === -1) {
390
+ const agentIndexes = [];
391
+ for (var i = 0; i < entries.length; i++) {
392
+ if (entries[i].agent === agent) agentIndexes.push(i);
393
+ }
394
+ if (agentIndexes.length === 0) {
395
+ throw new Error(
396
+ 'fail: no entry found for operation_id ' + operation_id +
397
+ ' and agent "' + agent + '" in ' + ledgerPath +
398
+ ' — operation was never opened'
399
+ );
400
+ }
401
+ if (agentIndexes.length > 1) {
402
+ throw new Error(
403
+ 'fail: ambiguous agent fallback — ' + agentIndexes.length +
404
+ ' entries match agent "' + agent + '" in ' + ledgerPath
405
+ );
406
+ }
407
+ idx = agentIndexes[0];
408
+ }
409
+
410
+ const existing = entries[idx];
411
+ const completed_at = new Date().toISOString();
412
+
413
+ const update = {
414
+ status: 'failed',
415
+ completed_at: completed_at,
416
+ };
417
+ if (error) {
418
+ update.error = _sanitizeError(error);
419
+ }
420
+
421
+ entries[idx] = Object.assign({}, existing, update);
422
+
423
+ _writeLedger(ledgerPath, entries);
424
+
425
+ const returnedOpId = existing.operation_id != null ? existing.operation_id : operation_id;
426
+ return { status: 'ok', operation_id: returnedOpId, entry: entries[idx] };
427
+ } finally {
428
+ _releaseLock(lockPath, lockHandle);
429
+ }
430
+ }
431
+
432
+ function skip(dir, prefix, agent, phase, model, attempt) {
433
+ if (attempt == null) attempt = 1;
434
+
435
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
436
+ const lockPath = ledgerPath + '.lock';
437
+
438
+ const lockHandle = _acquireLock(lockPath);
439
+ try {
440
+ const { entries } = _readLedger(ledgerPath);
441
+ const operation_id = computeOperationId(prefix, agent, attempt);
442
+
443
+ // Primary lookup: by operation_id (deterministic, unambiguous)
444
+ let idx = entries.findIndex(function (e) {
445
+ return e.operation_id === operation_id;
446
+ });
447
+
448
+ // Fallback: by agent name — only when no exact operation_id match
449
+ if (idx === -1) {
450
+ const agentIndexes = [];
451
+ for (var i = 0; i < entries.length; i++) {
452
+ if (entries[i].agent === agent) agentIndexes.push(i);
453
+ }
454
+ if (agentIndexes.length > 1) {
455
+ throw new Error(
456
+ 'skip: ambiguous agent fallback — ' + agentIndexes.length +
457
+ ' entries match agent "' + agent + '" in ' + ledgerPath
458
+ );
459
+ }
460
+ if (agentIndexes.length === 1) {
461
+ idx = agentIndexes[0];
462
+ }
463
+ }
464
+
465
+ if (idx === -1) {
466
+ // Case 1: NO existing match — atomically create a new terminal skipped entry.
467
+ // started_at and completed_at are set to the same timestamp (AC-23).
468
+ const ts = new Date().toISOString();
469
+ entries.push({
470
+ operation_id: operation_id,
471
+ agent: agent,
472
+ phase: phase,
473
+ model: model,
474
+ status: 'skipped',
475
+ started_at: ts,
476
+ completed_at: ts,
477
+ phase_delta_tokens: null,
478
+ });
479
+
480
+ _writeLedger(ledgerPath, entries);
481
+
482
+ return { status: 'ok', operation_id: operation_id, entry: entries[entries.length - 1] };
483
+ }
484
+
485
+ // Case 2: EXACTLY ONE match — update in place, preserving started_at and all other fields.
486
+ const existing = entries[idx];
487
+ const completed_at = new Date().toISOString();
488
+
489
+ entries[idx] = Object.assign({}, existing, {
490
+ status: 'skipped',
491
+ completed_at: completed_at,
492
+ });
493
+
494
+ _writeLedger(ledgerPath, entries);
495
+
496
+ const returnedOpId = existing.operation_id != null ? existing.operation_id : operation_id;
497
+ return { status: 'ok', operation_id: returnedOpId, entry: entries[idx] };
498
+ } finally {
499
+ _releaseLock(lockPath, lockHandle);
500
+ }
501
+ }
502
+
503
+ module.exports = {
504
+ open,
505
+ close,
506
+ fail,
507
+ skip,
508
+ computeOperationId,
509
+ _readLedger,
510
+ _writeLedger,
511
+ _backupCorruptFile,
512
+ _acquireLock,
513
+ _releaseLock,
514
+ _isLockStale,
515
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -80,56 +80,53 @@ Ask the user to **confirm or correct the draft**. Then grill ONLY on the `❓` g
80
80
 
81
81
  ## Phase 1 — Setup
82
82
 
83
- ### 1a. Discover the next FTR number
83
+ ### 1a. Resolve the features root (MANDATORY — single source of truth)
84
84
 
85
- Scan `docs/features/` for existing folders matching the pattern `FTR-[0-9]+*`. Extract the highest number and increment by 1. If no folders exist, start at `FTR-001`.
85
+ Before any file-system operation, run the following command **once** via Bash and capture its stdout into `featuresRoot`:
86
86
 
87
+ ```bash
88
+ featuresRoot=$(ai-toolkit resolve-features-root) && echo "$featuresRoot"
87
89
  ```
88
- docs/features/FTR-001-user-management/ → max = 1
89
- docs/features/FTR-002-product-catalog/ → max = 2
90
+
91
+ If the command exits non-zero, stop immediately and report the error to the user — do not proceed.
92
+
93
+ This single captured value (`featuresRoot`) is the **sole source of truth** for the features root in every subsequent step. Do **not** hard-code `docs/features/`, `internal_docs/features/`, or any other path. Both the directory under which `feature.md` is written (step 1d) and the `--dir` argument passed to the ledger facade must use this exact value — they must always agree, with no divergent or duplicate roots.
94
+
95
+ ### 1b. Discover the next FTR number
96
+
97
+ Scan `{featuresRoot}` for existing folders matching the pattern `FTR-[0-9]+*`. Extract the highest number and increment by 1. If no folders exist, start at `FTR-001`.
98
+
99
+ ```
100
+ {featuresRoot}/FTR-001-user-management/ → max = 1
101
+ {featuresRoot}/FTR-002-product-catalog/ → max = 2
90
102
  → next = FTR-003
91
103
  ```
92
104
 
93
- ### 1b. Ask for a feature name
105
+ ### 1c. Ask for a feature name
94
106
 
95
107
  Ask the user: **"What is the name of this feature?"** (short, descriptive, in their language). Use it to build the folder slug in kebab-case.
96
108
 
97
109
  Example: "Supplier Onboarding" → `FTR-003-supplier-onboarding`
98
110
 
99
- ### 1c. Create feature directory and initialize ledger
111
+ ### 1d. Create feature directory and open ledger entry
100
112
 
101
- Once you know the PREFIX (from 1a) and the feature slug (from 1b), do the following **before asking any grilling questions**:
113
+ Once you know the PREFIX (from 1b) and the feature slug (from 1c), do the following **before asking any grilling questions**:
102
114
 
103
- 1. Compute the paths:
104
- - `featureDir` = `docs/features/{PREFIX}-{slug}` (e.g. `docs/features/FTR-003-supplier-onboarding`)
105
- - `ledgerPath` = `{featureDir}/{PREFIX}-token-ledger.json`
115
+ 1. Compute `featureDir` using the captured `featuresRoot`:
116
+ - `featureDir` = `{featuresRoot}/{PREFIX}-{slug}` (e.g. `{featuresRoot}/FTR-003-supplier-onboarding`)
117
+ - This path is also the `--dir` value for every `ai-toolkit ledger` call that follows.
106
118
 
107
119
  2. Create the feature directory via Bash:
108
120
  ```bash
109
- mkdir -p {featureDir}
121
+ mkdir -p "{featureDir}"
110
122
  ```
111
123
 
112
- 3. Capture the current UTC timestamp via Bash:
124
+ 3. Open the ledger entry via the facade. Run the following command via Bash (quote the `--dir` path to handle spaces):
113
125
  ```bash
114
- started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && echo $started_at
126
+ ai-toolkit ledger open --dir "{featureDir}" --prefix {PREFIX} --agent define-feature:define --phase define --model sonnet --attempt 1
115
127
  ```
116
128
 
117
- 4. Write the initial ledger file at `{ledgerPath}` using the Write tool with exactly this content (substitute the actual PREFIX and timestamp):
118
- ```json
119
- [
120
- {
121
- "agent": "define-feature:define",
122
- "phase": "define",
123
- "model": "sonnet",
124
- "status": "running",
125
- "phase_delta_tokens": 0,
126
- "started_at": "<started_at from step 3>",
127
- "completed_at": null
128
- }
129
- ]
130
- ```
131
-
132
- The ledger records that the define-feature agent is now running. Proceed to Phase 2.
129
+ The ledger now records that the define-feature agent is running. Proceed to Phase 2.
133
130
 
134
131
  ---
135
132
 
@@ -254,12 +251,12 @@ Synthesize all answers into a structured `feature.md` file.
254
251
  ### Output path
255
252
 
256
253
  ```
257
- docs/features/{PREFIX}-{slug}/feature.md
254
+ {featuresRoot}/{PREFIX}-{slug}/feature.md
258
255
  ```
259
256
 
260
- Example: `docs/features/FTR-003-supplier-onboarding/feature.md`
257
+ Example: `{featuresRoot}/FTR-003-supplier-onboarding/feature.md`
261
258
 
262
- The directory was created in Phase 1c. If for any reason it doesn't exist, create it now.
259
+ The directory was created in Phase 1d using `featuresRoot`. If for any reason it doesn't exist, create it now using the same `featuresRoot` path — do not substitute a different root.
263
260
 
264
261
  ### feature.md template
265
262
 
@@ -352,34 +349,13 @@ For each entity: name, key fields (name, type, constraints), relationships.
352
349
 
353
350
  ### 4b. Finalize ledger entry
354
351
 
355
- After writing `feature.md`, update the ledger entry to record successful completion:
352
+ After writing `feature.md`, close the ledger entry via the facade. Run the following command via Bash (quote the `--dir` path to handle spaces):
356
353
 
357
- 1. Capture the current UTC timestamp via Bash:
358
- ```bash
359
- completed_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && echo $completed_at
360
- ```
354
+ ```bash
355
+ ai-toolkit ledger close --dir "{featureDir}" --prefix {PREFIX} --agent define-feature:define --attempt 1
356
+ ```
361
357
 
362
- 2. Read the ledger file at `{featureDir}/{PREFIX}-token-ledger.json` using the Read tool.
363
-
364
- 3. Write the updated ledger file using the Write tool, replacing the entry's fields:
365
- - `"status"`: `"done"`
366
- - `"completed_at"`: the timestamp captured in step 1
367
- - `"phase_delta_tokens"`: leave as `0` — token measurement is not available to the agent directly
368
-
369
- Example final ledger content:
370
- ```json
371
- [
372
- {
373
- "agent": "define-feature:define",
374
- "phase": "define",
375
- "model": "sonnet",
376
- "status": "done",
377
- "phase_delta_tokens": 0,
378
- "started_at": "<original started_at>",
379
- "completed_at": "<completed_at from step 1>"
380
- }
381
- ]
382
- ```
358
+ **Important:** `--tokens` is intentionally omitted. The ledger facade records `phase_delta_tokens` as `null` when `--tokens` is not supplied. `define-feature` is an interactive/LLM activity whose token consumption cannot be observed from within the agent itself — do **not** pass `0` or any estimate.
383
359
 
384
360
  ---
385
361
 
@@ -392,7 +368,7 @@ After writing `feature.md`:
392
368
  3. Tell the user the next step:
393
369
 
394
370
  ```
395
- ✅ feature.md written at docs/features/{PREFIX}-{slug}/feature.md
371
+ ✅ feature.md written at {featuresRoot}/{PREFIX}-{slug}/feature.md
396
372
 
397
373
  Summary:
398
374
  Actors: N
@@ -400,7 +376,7 @@ Summary:
400
376
  Acceptance criteria: N (Must: N, Should: N, Could: N)
401
377
  Open questions: N
402
378
 
403
- Next step: run /implement-feature docs/features/{PREFIX}-{slug}/feature.md
379
+ Next step: run /implement-feature {featuresRoot}/{PREFIX}-{slug}/feature.md
404
380
  to start the full pipeline (requirements → spec → implementation → PR).
405
381
  ```
406
382