@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/.claude/agents/define-feature.md +67 -1
- package/.claude/agents/install-toolkit.md +34 -0
- package/.claude/workflows/pm-phase1.js +78 -0
- package/.claude/workflows/pm-phase2.js +103 -26
- package/.claude/workflows/pm-phase3.js +98 -20
- package/CHANGELOG.md +42 -0
- package/bin/cli.js +428 -3
- package/docs/token-pricing.json +21 -0
- package/package.json +1 -1
- package/docs/token-optimization-backlog.md +0 -115
- package/docs/token-optimization.md +0 -274
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
|
+
}
|