@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.
- package/CHANGELOG.md +37 -0
- package/bin/cli.js +279 -63
- package/lib/execution-ledger.js +515 -0
- package/package.json +1 -1
- package/src/claude/agents/define-feature.md +36 -60
- package/src/claude/scripts/wb-render.js +101 -2
- package/src/claude/scripts/wb-validate.js +32 -2
- package/src/claude/skills/implement-feature/SKILL.md +4 -2
- package/src/claude/workflows/pm-phase1.js +25 -75
- package/src/claude/workflows/pm-phase2.js +55 -106
- package/src/claude/workflows/pm-phase3.js +162 -75
|
@@ -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
|
@@ -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.
|
|
83
|
+
### 1a. Resolve the features root (MANDATORY — single source of truth)
|
|
84
84
|
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
111
|
+
### 1d. Create feature directory and open ledger entry
|
|
100
112
|
|
|
101
|
-
Once you know the PREFIX (from
|
|
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
|
|
104
|
-
- `featureDir` = `
|
|
105
|
-
- `
|
|
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.
|
|
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
|
-
|
|
126
|
+
ai-toolkit ledger open --dir "{featureDir}" --prefix {PREFIX} --agent define-feature:define --phase define --model sonnet --attempt 1
|
|
115
127
|
```
|
|
116
128
|
|
|
117
|
-
|
|
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
|
-
|
|
254
|
+
{featuresRoot}/{PREFIX}-{slug}/feature.md
|
|
258
255
|
```
|
|
259
256
|
|
|
260
|
-
Example: `
|
|
257
|
+
Example: `{featuresRoot}/FTR-003-supplier-onboarding/feature.md`
|
|
261
258
|
|
|
262
|
-
The directory was created in Phase
|
|
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`,
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
```
|
|
354
|
+
```bash
|
|
355
|
+
ai-toolkit ledger close --dir "{featureDir}" --prefix {PREFIX} --agent define-feature:define --attempt 1
|
|
356
|
+
```
|
|
361
357
|
|
|
362
|
-
|
|
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
|
|
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
|
|
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
|
|