@dtlabs/ai-toolkit 0.7.0 → 0.8.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 CHANGED
@@ -1,4 +1,46 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.8.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.7.0...v0.8.0) (2026-08-04)
3
+
4
+
5
+ ### Features
6
+
7
+ * **FTR-011:** implement shared infrastructure (INFRA) ([718b0eb](https://github.com/dtlab-tech/ai-toolkit/commit/718b0ebe684814028b0457351a40b171ea80a197))
8
+ * **FTR-011:** implement US-01 — Implement readManifest() Function ([aad7de1](https://github.com/dtlab-tech/ai-toolkit/commit/aad7de19aa6aaf48578c46eebd3bb236ebb4fadb))
9
+ * **FTR-011:** implement US-02 — Implement computeOrphans() Function ([3a70517](https://github.com/dtlab-tech/ai-toolkit/commit/3a705173e649aa7782d036b5790c830c88a1d797))
10
+ * **FTR-011:** implement US-03 — Implement moveToTrash() Function ([a6ac7d2](https://github.com/dtlab-tech/ai-toolkit/commit/a6ac7d2d841905bba24f5349a7d763c9f43527e8))
11
+ * **FTR-011:** implement US-04 — Implement writeManifest() Function ([dc43ea1](https://github.com/dtlab-tech/ai-toolkit/commit/dc43ea1e35c2e4981876ad5f46b3138890851fcd))
12
+ * **FTR-011:** implement US-05 — Integrate Prune Phase into runInstall() and Add UI Display ([bca87ea](https://github.com/dtlab-tech/ai-toolkit/commit/bca87ea8f8843f6a8a07371eaef072b1fc004453))
13
+ * **FTR-011:** implement US-06 — Add CI Safety Net — Agent Name-to-Filename Alignment Check ([06db318](https://github.com/dtlab-tech/ai-toolkit/commit/06db3180b4867b611a2793bf2c74e8510e66827c))
14
+ * **FTR-011:** Installer Manifest and Orphan Pruning ([764eff4](https://github.com/dtlab-tech/ai-toolkit/commit/764eff4dd3b13ee34e7e4be7159c08168e49fe1d))
15
+ * **FTR-011:** update effort and token estimates, add implementation summary, and log completion details ([ce5660d](https://github.com/dtlab-tech/ai-toolkit/commit/ce5660dec691b75bb501bcadb149c2f4eaabdb5a))
16
+ * **FTR-012:** implement shared infrastructure (INFRA) ([da14ae1](https://github.com/dtlab-tech/ai-toolkit/commit/da14ae15d19301c47231e73872ecba75f180e739))
17
+ * **FTR-012:** implement US-01 — Fresh Installation with Allowlist Opt-In ([803018a](https://github.com/dtlab-tech/ai-toolkit/commit/803018aafe77abb06390849540f7fb214fccbc40))
18
+ * **FTR-012:** implement US-02 — Merge Allowlist into Existing Settings ([9855abc](https://github.com/dtlab-tech/ai-toolkit/commit/9855abc940443d4e75b7a1b3340715d8e1403fcf))
19
+ * **FTR-012:** implement US-03 — Ask-Beats-Allow Conflict Resolution ([9c51984](https://github.com/dtlab-tech/ai-toolkit/commit/9c5198490824647bac7b1850b09e624d9f00e883))
20
+ * **FTR-012:** implement US-04 — Reinstall with Idempotent Merge ([8e9a49f](https://github.com/dtlab-tech/ai-toolkit/commit/8e9a49f69d7bb761a698b2ac7f7a405d5d2d833e))
21
+ * **FTR-012:** implement US-05 — .gitignore Creation and Idempotent Update ([20e19de](https://github.com/dtlab-tech/ai-toolkit/commit/20e19de3c38cbbfcd5dd27c8b8b5ab629036b101))
22
+ * **FTR-012:** Installer Bash Allowlist ([2deb73e](https://github.com/dtlab-tech/ai-toolkit/commit/2deb73ef4ad4189c6eea1ae46ec0f0dc6ece469e))
23
+ * **FTR-012:** mark feature as completed in registry ([ab90d58](https://github.com/dtlab-tech/ai-toolkit/commit/ab90d5878a4091ccaef38863c14634ebd2082623))
24
+ * **FTR-013:** add additional agent entries for US-03 and US-04 in ledger ([6f756ed](https://github.com/dtlab-tech/ai-toolkit/commit/6f756edfaa0d75b48809aa3964261efa65fd69f9))
25
+ * **FTR-013:** add registry entry and update ledger actuals ([23f5138](https://github.com/dtlab-tech/ai-toolkit/commit/23f513801486c36e95782ea513e15fcfcb755d06))
26
+ * **FTR-013:** implement shared infrastructure (INFRA) ([99d564f](https://github.com/dtlab-tech/ai-toolkit/commit/99d564f8ca543b7cd95c5cf1a5687961ef402921))
27
+ * **FTR-013:** implement US-01 — Initialize and Track Ledger in define-feature Agent ([ce0b010](https://github.com/dtlab-tech/ai-toolkit/commit/ce0b010afd40e94f9e9e2618eca1fbcc60ede7ae))
28
+ * **FTR-013:** implement US-01 — Initialize and Track Ledger in define-feature Agent ([ca84538](https://github.com/dtlab-tech/ai-toolkit/commit/ca84538d6980828313d331e4a07e10bde5cb34c7))
29
+ * **FTR-013:** implement US-02 — Track Phase 1 Agent Invocations ([a4e3b10](https://github.com/dtlab-tech/ai-toolkit/commit/a4e3b1001bb0b6201c873c4608c23c94c37ca5e0))
30
+ * **FTR-013:** implement US-02 — Track Phase 1 Agent Invocations ([85d86e4](https://github.com/dtlab-tech/ai-toolkit/commit/85d86e406d9ace96b22214de5aef14a6b0cde0b2))
31
+ * **FTR-013:** implement US-03 — Track Phase 2 Agent Invocations ([819f3c3](https://github.com/dtlab-tech/ai-toolkit/commit/819f3c33a954ce89bb221461ee21092b9429c085))
32
+ * **FTR-013:** implement US-03 — Track Phase 2 Agent Invocations ([7d6f9bb](https://github.com/dtlab-tech/ai-toolkit/commit/7d6f9bba8a794e83a12b31a306e27a5d8505c3f7))
33
+ * **FTR-013:** Ledger Pipeline Activity Tracker ([f1bf49a](https://github.com/dtlab-tech/ai-toolkit/commit/f1bf49a4c7496fba8f0c04c5770fe2ad08a6b4ac))
34
+ * **pm:** add EUR cost columns to Token-Estimate ([b0faf0d](https://github.com/dtlab-tech/ai-toolkit/commit/b0faf0dc15c40358bf64b55a0a34987be8db19a2))
35
+
36
+
37
+ ### Bug Fixes
38
+
39
+ * **FTR-011:** remediate review issues ([c34d824](https://github.com/dtlab-tech/ai-toolkit/commit/c34d8246fff5548401cb50cc300043679ede4bba))
40
+ * **FTR-012:** remediate review issues ([f9ea280](https://github.com/dtlab-tech/ai-toolkit/commit/f9ea280c394a6436fa6a0840f0110b1b620b6025))
41
+ * **pm:** commit actuals after write-actuals; restore zero-delta ledger entries on resume ([83bbaec](https://github.com/dtlab-tech/ai-toolkit/commit/83bbaecdbbbbf9aa436af8d56a6e823500868b1b))
42
+ * **pm:** correct write-actuals token estimate output ([d45653a](https://github.com/dtlab-tech/ai-toolkit/commit/d45653a2c9f999ec8478836e01128428295fa95a))
43
+
2
44
  ## [0.7.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.6.0...v0.7.0) (2026-07-29)
3
45
 
4
46
 
package/bin/cli.js CHANGED
@@ -67,6 +67,92 @@ function askConfirm(question) {
67
67
  });
68
68
  }
69
69
 
70
+ // ── allowlist ─────────────────────────────────────────────────────────────────
71
+
72
+ // INFRA-T01: Canonical Bash permission lists (FTR-012, AC-11, AC-12).
73
+ // These are fixed baselines — one combined list covering base read-only,
74
+ // .NET, and npm commands. Unused entries in a project are harmless.
75
+ const CANONICAL_ALLOW = [
76
+ 'ls', 'dir', 'cat', 'head', 'tail', 'find', 'grep', 'rg', 'wc', 'echo',
77
+ 'pwd', 'which', 'date',
78
+ 'git status', 'git diff', 'git log', 'git show', 'git branch', 'git rev-parse',
79
+ 'git add', 'git commit',
80
+ 'dotnet build', 'dotnet test', 'dotnet restore',
81
+ 'npm test', 'npm run build',
82
+ ];
83
+
84
+ // Dangerous outward-facing commands that must always surface a human prompt.
85
+ // git checkout stays on ask: it runs only in the implement-feature main loop
86
+ // (Step 5), never inside pm-phase3 worker agents. If pm-phase3 ever gains
87
+ // branch-management steps, this decision must be revisited.
88
+ const CANONICAL_ASK = [
89
+ 'git push', 'gh pr create', 'rm', 'del',
90
+ 'git checkout', 'git reset', 'git clean',
91
+ ];
92
+
93
+ // INFRA-T01: Format a raw command string into a Claude Code permission token.
94
+ // Every command — including no-argument ones like 'pwd' — is formatted as
95
+ // 'Bash(<cmd>:*)' for consistency across all entries.
96
+ function commandToPermission(cmd) {
97
+ return `Bash(${cmd}:*)`;
98
+ }
99
+
100
+ // INFRA-T02: Ensure an object parsed from settings.local.json has the expected
101
+ // shape. Mutates and returns the object so callers can chain immediately.
102
+ function normalizeSettings(obj) {
103
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) obj = {};
104
+ if (!obj.permissions || typeof obj.permissions !== 'object') obj.permissions = {};
105
+ if (!obj.permissions.Bash || typeof obj.permissions.Bash !== 'object') obj.permissions.Bash = {};
106
+ if (!Array.isArray(obj.permissions.Bash.allow)) obj.permissions.Bash.allow = [];
107
+ if (!Array.isArray(obj.permissions.Bash.ask)) obj.permissions.Bash.ask = [];
108
+ return obj;
109
+ }
110
+
111
+ // INFRA-T03: Return the dedup union of two arrays (preserves insertion order,
112
+ // all elements of a appear before novel elements of b).
113
+ function mergeArrays(a, b) {
114
+ return [...new Set([...a, ...b])];
115
+ }
116
+
117
+ // INFRA-T03: Remove from allow every entry that also appears in ask.
118
+ // This enforces the ask-beats-allow invariant: a command that could be
119
+ // dangerous is never silently auto-approved just because it was also listed
120
+ // in an allow array.
121
+ function applyAskBeatsAllow(allow, ask) {
122
+ const askSet = new Set(ask);
123
+ return allow.filter(cmd => !askSet.has(cmd));
124
+ }
125
+
126
+ // INFRA-T04: Read {destDir}/.claude/settings.local.json.
127
+ // Returns a descriptor so callers can distinguish missing vs. malformed.
128
+ // { exists: false, data: null, malformed: false } — file absent
129
+ // { exists: true, data: <obj>, malformed: false } — parsed OK
130
+ // { exists: true, data: null, malformed: true } — invalid JSON
131
+ function readSettings(destDir) {
132
+ const settingsPath = path.join(destDir, '.claude', 'settings.local.json');
133
+ if (!fs.existsSync(settingsPath)) return { exists: false, data: null, malformed: false };
134
+ let raw;
135
+ try { raw = fs.readFileSync(settingsPath, 'utf8'); } catch (err) {
136
+ console.error(`Warning: could not read settings.local.json — ${err.message}`);
137
+ return { exists: true, data: null, malformed: true };
138
+ }
139
+ try {
140
+ const data = JSON.parse(raw);
141
+ return { exists: true, data, malformed: false };
142
+ } catch {
143
+ return { exists: true, data: null, malformed: true };
144
+ }
145
+ }
146
+
147
+ // INFRA-T04: Write {destDir}/.claude/settings.local.json, creating the
148
+ // .claude/ directory if it does not exist. Throws on I/O failure so callers
149
+ // can catch and report gracefully.
150
+ function writeSettings(destDir, data) {
151
+ const settingsPath = path.join(destDir, '.claude', 'settings.local.json');
152
+ ensureDir(settingsPath);
153
+ fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2), 'utf8');
154
+ }
155
+
70
156
  // ── file enumeration ─────────────────────────────────────────────────────────
71
157
 
72
158
  function expandMappings(mappings) {
@@ -155,9 +241,71 @@ async function checkVersion(destRoot, force) {
155
241
  return true;
156
242
  }
157
243
 
244
+ // ── manifest ──────────────────────────────────────────────────────────────────
245
+
246
+ const MANIFEST_FILE = '.ai-toolkit-manifest.json';
247
+
248
+ function readManifest(destRoot) {
249
+ const manifestPath = path.join(destRoot, '.claude', MANIFEST_FILE);
250
+ if (!fs.existsSync(manifestPath)) return { files: [] };
251
+ try {
252
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
253
+ if (!parsed || !Array.isArray(parsed.files)) return { files: [] };
254
+ parsed.files = parsed.files.map(f => f.replace(/\\/g, '/'));
255
+ return parsed;
256
+ } catch {
257
+ console.log(dim('Previous manifest is corrupt; treating as empty'));
258
+ return { files: [] };
259
+ }
260
+ }
261
+
262
+ function computeOrphans(oldFiles, newFiles) {
263
+ const newSet = new Set(newFiles.map(f => f.replace(/\\/g, '/')));
264
+ return oldFiles
265
+ .map(f => f.replace(/\\/g, '/'))
266
+ .filter(f => !newSet.has(f));
267
+ }
268
+
269
+ function moveToTrash(destRoot, relativePath) {
270
+ const source = path.join(destRoot, relativePath);
271
+ if (!fs.existsSync(source)) return;
272
+ const trashPath = path.join(destRoot, '.claude', '.ai-toolkit-trash', relativePath);
273
+ ensureDir(trashPath);
274
+ try {
275
+ fs.renameSync(source, trashPath);
276
+ } catch (err) {
277
+ if (err.code === 'EXDEV') {
278
+ fs.copyFileSync(source, trashPath);
279
+ fs.unlinkSync(source);
280
+ } else {
281
+ throw err;
282
+ }
283
+ }
284
+ }
285
+
286
+ function writeManifest(destRoot, fileList) {
287
+ const manifestPath = path.join(destRoot, '.claude', MANIFEST_FILE);
288
+ const trashDir = path.join(destRoot, '.claude', '.ai-toolkit-trash');
289
+ const filtered = fileList.filter(rel => {
290
+ const abs = path.join(destRoot, rel);
291
+ return !abs.startsWith(trashDir + path.sep) && abs !== trashDir;
292
+ });
293
+ const manifest = {
294
+ version: TOOLKIT_VERSION,
295
+ installedAt: new Date().toISOString(),
296
+ files: filtered.map(f => f.replace(/\\/g, '/')),
297
+ };
298
+ try {
299
+ ensureDir(manifestPath);
300
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
301
+ } catch (err) {
302
+ console.log(dim(`Warning: could not write manifest — ${err.message}`));
303
+ }
304
+ }
305
+
158
306
  // ── install ───────────────────────────────────────────────────────────────────
159
307
 
160
- async function runInstall(label, mappings, force) {
308
+ async function runInstall(label, mappings, force, destRoot) {
161
309
  const files = expandMappings(mappings);
162
310
  const entries = categorize(files);
163
311
 
@@ -165,6 +313,58 @@ async function runInstall(label, mappings, force) {
165
313
  const modified = entries.filter(e => e.status === 'modified');
166
314
  const same = entries.filter(e => e.status === 'same');
167
315
 
316
+ // ── prune phase (before file copy) ───────────────────────────────────────────
317
+ const oldManifest = readManifest(destRoot);
318
+ const newFileSet = files.map(f => path.relative(destRoot, f.dest).replace(/\\/g, '/'));
319
+ const orphans = computeOrphans(oldManifest.files, newFileSet);
320
+
321
+ const existingOrphans = orphans.filter(o => fs.existsSync(path.join(destRoot, o)));
322
+
323
+ if (existingOrphans.length > 0) {
324
+ console.log();
325
+ console.log(`${bold('📦 Orphan cleanup')} ${clr('gray', '→')} ${clr('red', `${existingOrphans.length} stale file(s) found`)}`);
326
+ console.log(divider());
327
+ for (const orphan of existingOrphans) {
328
+ console.log(` ${clr('red', '∅')} ${clr('red', 'REMOVED ')} ${orphan}`);
329
+ }
330
+ console.log(divider());
331
+ }
332
+
333
+ let removedCount = 0;
334
+ let keptCount = 0;
335
+
336
+ if (force) {
337
+ for (const orphan of existingOrphans) {
338
+ moveToTrash(destRoot, orphan);
339
+ removedCount++;
340
+ console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
341
+ }
342
+ } else {
343
+ for (const orphan of existingOrphans) {
344
+ const fullPath = path.join(destRoot, orphan);
345
+ if (!fs.existsSync(fullPath)) continue;
346
+ const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
347
+ if (ok) {
348
+ moveToTrash(destRoot, orphan);
349
+ console.log(` ${clr('green', '✔')} Moved to trash\n`);
350
+ removedCount++;
351
+ } else {
352
+ console.log(` ${clr('gray', '✖')} Kept as-is\n`);
353
+ keptCount++;
354
+ }
355
+ }
356
+ }
357
+
358
+ if (existingOrphans.length > 0) {
359
+ console.log(divider());
360
+ console.log(
361
+ ` ${clr('red', `∅ Moved: ${removedCount}`)}` +
362
+ ` ${clr('gray', `↪ .claude/.ai-toolkit-trash/`)}` +
363
+ ` ${clr('gray', `✖ Kept: ${keptCount}`)}\n`
364
+ );
365
+ }
366
+
367
+ // ── install plan display ──────────────────────────────────────────────────────
168
368
  console.log();
169
369
  console.log(`${bold('📦 Install plan')} ${clr('gray', '→')} ${clr('cyan', label)}`);
170
370
  console.log(divider());
@@ -186,6 +386,7 @@ async function runInstall(label, mappings, force) {
186
386
 
187
387
  if (modified.length === 0) {
188
388
  console.log(` ${clr('green', '✔')} All new files copied. No conflicts.\n`);
389
+ writeManifest(destRoot, newFileSet);
189
390
  return;
190
391
  }
191
392
 
@@ -196,6 +397,7 @@ async function runInstall(label, mappings, force) {
196
397
  fs.copyFileSync(e.src, e.dest);
197
398
  console.log(` ${clr('yellow', '↺')} ${dim(path.relative(process.cwd(), e.dest))}`);
198
399
  }
400
+ writeManifest(destRoot, newFileSet);
199
401
  return;
200
402
  }
201
403
 
@@ -223,6 +425,8 @@ async function runInstall(label, mappings, force) {
223
425
  ` ${clr('green', `✔ Overwritten: ${overwritten}`)}` +
224
426
  ` ${clr('gray', `✖ Kept as-is: ${skipped}`)}\n`
225
427
  );
428
+
429
+ writeManifest(destRoot, newFileSet);
226
430
  }
227
431
 
228
432
  // ── subagent spawn-depth check (verify & advise only — never write) ────────────
@@ -271,6 +475,175 @@ function checkSpawnDepth(destRoot) {
271
475
  console.log(` ${dim('Then restart Claude Code so the variable is loaded.')}\n`);
272
476
  }
273
477
 
478
+ // ── mergeAllowlist ────────────────────────────────────────────────────────────
479
+
480
+ // US-01-T01 / US-02-T01 / US-02-T02 / US-03-T01:
481
+ // Core pure function — creates or merges the Bash permission allowlist in
482
+ // {destDir}/.claude/settings.local.json using the canonical lists above.
483
+ //
484
+ // Return values:
485
+ // { status: 'written' } — fresh file created
486
+ // { status: 'merged', preserved: N } — merged with N pre-existing rules
487
+ // { status: 'reset', reason: 'malformed' } — invalid JSON was replaced
488
+ // { status: 'error', message: '...' } — I/O failure
489
+ function mergeAllowlist(destDir) {
490
+ if (!destDir || typeof destDir !== 'string') {
491
+ return { status: 'error', message: 'destDir must be a non-empty string' };
492
+ }
493
+ if (!fs.existsSync(destDir) || !fs.statSync(destDir).isDirectory()) {
494
+ return { status: 'error', message: `destination does not exist or is not a directory: ${destDir}` };
495
+ }
496
+
497
+ const canonicalAllow = CANONICAL_ALLOW.map(commandToPermission);
498
+ const canonicalAsk = CANONICAL_ASK.map(commandToPermission);
499
+
500
+ const { exists, data, malformed } = readSettings(destDir);
501
+
502
+ // Malformed JSON recovery (AC-05)
503
+ if (malformed) {
504
+ console.log('Warning: settings.local.json is not valid JSON; resetting to default');
505
+ const fresh = normalizeSettings({});
506
+ fresh.permissions.Bash.allow = canonicalAllow;
507
+ fresh.permissions.Bash.ask = canonicalAsk;
508
+ try {
509
+ writeSettings(destDir, fresh);
510
+ } catch (err) {
511
+ return { status: 'error', message: err.message };
512
+ }
513
+ return { status: 'reset', reason: 'malformed' };
514
+ }
515
+
516
+ // Fresh install: no existing file (AC-01)
517
+ if (!exists) {
518
+ const fresh = normalizeSettings({});
519
+ fresh.permissions.Bash.allow = canonicalAllow;
520
+ fresh.permissions.Bash.ask = canonicalAsk;
521
+ try {
522
+ writeSettings(destDir, fresh);
523
+ } catch (err) {
524
+ return { status: 'error', message: err.message };
525
+ }
526
+ return { status: 'written' };
527
+ }
528
+
529
+ // Merge path: existing file, valid JSON (AC-02, AC-03, AC-04)
530
+ const existing = normalizeSettings(data);
531
+ const existingAllow = existing.permissions.Bash.allow;
532
+ const existingAsk = existing.permissions.Bash.ask;
533
+
534
+ const countBefore = existingAllow.length + existingAsk.length;
535
+
536
+ let mergedAllow = mergeArrays(existingAllow, canonicalAllow);
537
+ const mergedAsk = mergeArrays(existingAsk, canonicalAsk);
538
+
539
+ // ask-beats-allow: strip from allow any cmd that is now in ask
540
+ mergedAllow = applyAskBeatsAllow(mergedAllow, mergedAsk);
541
+
542
+ existing.permissions.Bash.allow = mergedAllow;
543
+ existing.permissions.Bash.ask = mergedAsk;
544
+
545
+ try {
546
+ writeSettings(destDir, existing);
547
+ } catch (err) {
548
+ return { status: 'error', message: err.message };
549
+ }
550
+
551
+ const preserved = countBefore;
552
+ return { status: 'merged', preserved };
553
+ }
554
+
555
+ // INFRA-T01 (FTR-013):
556
+ // Append a new entry to {featureDir}/{prefix}-token-ledger.json atomically.
557
+ // If the file does not exist it is created. If JSON is malformed the file is
558
+ // overwritten with a single-element array containing the new entry.
559
+ //
560
+ // Algorithm:
561
+ // 1. Read and parse the existing ledger (or start with [])
562
+ // 2. Push the new entry
563
+ // 3. Write the full array back in one synchronous write (atomic)
564
+ function appendLedgerEntry(featureDir, prefix, entry) {
565
+ const filePath = path.join(featureDir, `${prefix}-token-ledger.json`);
566
+ let ledger = [];
567
+ if (fs.existsSync(filePath)) {
568
+ try {
569
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
570
+ ledger = Array.isArray(parsed) ? parsed : [];
571
+ } catch (_) {
572
+ console.log(`Warning: could not parse token ledger at ${filePath} — starting fresh`);
573
+ ledger = [];
574
+ }
575
+ }
576
+ ledger.push(entry);
577
+ const dir = path.dirname(filePath);
578
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
579
+ fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2), 'utf8');
580
+ }
581
+
582
+ // INFRA-T02 (FTR-013):
583
+ // Find and update an existing entry in {featureDir}/{prefix}-token-ledger.json
584
+ // by agent key, atomically. Searches from the end of the array so that the most
585
+ // recent entry for a given key is updated (handles any accidental duplicates).
586
+ // If the file does not exist or the key is not found the call is a silent no-op.
587
+ //
588
+ // Algorithm:
589
+ // 1. Read and parse the existing ledger (silent return on missing/malformed)
590
+ // 2. Find the last entry where entry.agent === agentKey
591
+ // 3. Object.assign the updates onto that entry
592
+ // 4. Write the full array back in one synchronous write (atomic)
593
+ function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
594
+ const filePath = path.join(featureDir, `${prefix}-token-ledger.json`);
595
+ if (!fs.existsSync(filePath)) return;
596
+ let ledger;
597
+ try {
598
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
599
+ ledger = Array.isArray(parsed) ? parsed : [];
600
+ } catch (_) {
601
+ console.log(`Warning: could not parse token ledger at ${filePath} — skipping update for "${agentKey}"`);
602
+ return;
603
+ }
604
+ let idx = -1;
605
+ for (let i = ledger.length - 1; i >= 0; i--) {
606
+ if (ledger[i] && ledger[i].agent === agentKey) { idx = i; break; }
607
+ }
608
+ if (idx === -1) {
609
+ console.log(`Warning: ledger entry not found for agent key "${agentKey}"`);
610
+ return;
611
+ }
612
+ Object.assign(ledger[idx], updates);
613
+ fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2), 'utf8');
614
+ }
615
+
616
+ // US-05-T01:
617
+ // Idempotently append `.claude/settings.local.json` to {destDir}/.gitignore.
618
+ // Creates .gitignore if it does not exist (AC-06, AC-07).
619
+ //
620
+ // Return values:
621
+ // { status: 'appended' } — line was added
622
+ // { status: 'already' } — line was already present
623
+ // { status: 'created' } — .gitignore did not exist; created with line
624
+ // { status: 'error', message } — I/O failure
625
+ function updateGitignore(destDir) {
626
+ const GITIGNORE_ENTRY = '.claude/settings.local.json';
627
+ const gitignorePath = path.join(destDir, '.gitignore');
628
+
629
+ try {
630
+ if (!fs.existsSync(gitignorePath)) {
631
+ fs.writeFileSync(gitignorePath, `${GITIGNORE_ENTRY}\n`, 'utf8');
632
+ return { status: 'created' };
633
+ }
634
+
635
+ const content = fs.readFileSync(gitignorePath, 'utf8');
636
+ const lines = content.split('\n').map(l => l.trim());
637
+ if (lines.includes(GITIGNORE_ENTRY)) return { status: 'already' };
638
+
639
+ const trailing = content.endsWith('\n') ? '' : '\n';
640
+ fs.writeFileSync(gitignorePath, `${content}${trailing}${GITIGNORE_ENTRY}\n`, 'utf8');
641
+ return { status: 'appended' };
642
+ } catch (err) {
643
+ return { status: 'error', message: err.message };
644
+ }
645
+ }
646
+
274
647
  // ── entry points ──────────────────────────────────────────────────────────────
275
648
 
276
649
  async function installLocal(targetDir, force) {
@@ -283,7 +656,7 @@ async function installLocal(targetDir, force) {
283
656
  { src: path.join(packageRoot, 'docs'), dest: path.join(targetDir, 'docs') },
284
657
  { src: path.join(packageRoot, 'CLAUDE.md'), dest: path.join(targetDir, 'CLAUDE.md') },
285
658
  ];
286
- await runInstall(`local project`, mappings, force);
659
+ await runInstall(`local project`, mappings, force, targetDir);
287
660
  writeInstalledVersion(targetDir);
288
661
  console.log(` ${clr('green', '✔')} ${bold('Install complete.')}\n`);
289
662
  checkSpawnDepth(targetDir);
@@ -309,7 +682,7 @@ async function installGlobal(force) {
309
682
  { src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
310
683
  { src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
311
684
  ];
312
- await runInstall('global Claude folder', mappings, force);
685
+ await runInstall('global Claude folder', mappings, force, target);
313
686
  writeInstalledVersion(target);
314
687
  console.log(` ${clr('green', '✔')} ${bold('Global install complete.')}\n`);
315
688
  checkSpawnDepth(homedir);
@@ -349,6 +722,42 @@ async function main() {
349
722
  await installGlobal(force);
350
723
  } else if (argv[0] === 'help' || argv[0] === '--help') {
351
724
  help();
725
+ } else if (argv[0] === 'merge-allowlist') {
726
+ const destDir = argv[1];
727
+ if (!destDir) {
728
+ console.error('Error: merge-allowlist requires a destination directory');
729
+ process.exit(1);
730
+ }
731
+ try {
732
+ const result = mergeAllowlist(destDir);
733
+ if (result.status === 'error') {
734
+ console.error(`Error: ${result.message}`);
735
+ process.exit(1);
736
+ }
737
+ console.log(`Allowlist: ${result.status}${result.preserved !== undefined ? ` (${result.preserved} rules preserved)` : ''}`);
738
+ process.exit(0);
739
+ } catch (err) {
740
+ console.error(`Error: ${err.message}`);
741
+ process.exit(1);
742
+ }
743
+ } else if (argv[0] === 'update-gitignore') {
744
+ const destDir = argv[1];
745
+ if (!destDir) {
746
+ console.error('Error: update-gitignore requires a destination directory');
747
+ process.exit(1);
748
+ }
749
+ try {
750
+ const result = updateGitignore(destDir);
751
+ if (result.status === 'error') {
752
+ console.error(`Error: ${result.message}`);
753
+ process.exit(1);
754
+ }
755
+ console.log(`Gitignore: ${result.status}`);
756
+ process.exit(0);
757
+ } catch (err) {
758
+ console.error(`Error: ${err.message}`);
759
+ process.exit(1);
760
+ }
352
761
  } else if (fs.existsSync(argv[0]) && fs.statSync(argv[0]).isDirectory()) {
353
762
  await installLocal(argv[0], force);
354
763
  } else {
@@ -371,5 +780,21 @@ if (require.main === module) {
371
780
  categorize,
372
781
  readInstalledVersion,
373
782
  NEVER_COPY,
783
+ readManifest,
784
+ computeOrphans,
785
+ moveToTrash,
786
+ writeManifest,
787
+ CANONICAL_ALLOW,
788
+ CANONICAL_ASK,
789
+ commandToPermission,
790
+ normalizeSettings,
791
+ mergeArrays,
792
+ applyAskBeatsAllow,
793
+ readSettings,
794
+ writeSettings,
795
+ mergeAllowlist,
796
+ updateGitignore,
797
+ appendLedgerEntry,
798
+ updateLedgerEntry,
374
799
  };
375
800
  }
@@ -0,0 +1,21 @@
1
+ {
2
+ "_comment": "Anthropic API pricing (USD per 1M tokens). Update when prices change. EUR conversion uses the rate below.",
3
+ "_source": "https://www.anthropic.com/pricing — last updated 2026-07-31",
4
+ "_split": "No per-agent input/output breakdown available in the ledger. Assume 80% input / 20% output as a reasonable approximation.",
5
+ "usd_to_eur": 0.92,
6
+ "usd_to_eur_date": "2026-07-31",
7
+ "models": {
8
+ "haiku": {
9
+ "input_per_1m_usd": 0.80,
10
+ "output_per_1m_usd": 4.00
11
+ },
12
+ "sonnet": {
13
+ "input_per_1m_usd": 3.00,
14
+ "output_per_1m_usd": 15.00
15
+ },
16
+ "opus": {
17
+ "input_per_1m_usd": 15.00,
18
+ "output_per_1m_usd": 75.00
19
+ }
20
+ }
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",