@dtlabs/ai-toolkit 0.6.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/developer-testing.md +4 -4
- 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 +152 -17
- package/CHANGELOG.md +51 -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/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,115 +0,0 @@
|
|
|
1
|
-
# Token Optimization — Backlog di feature atomiche
|
|
2
|
-
|
|
3
|
-
> Deriva da `docs/token-optimization.md`. Data: 2026-07-24.
|
|
4
|
-
> Ogni voce è **atomica**: implementabile e verificabile in isolamento.
|
|
5
|
-
> "Criticità" = priorità di ottimizzazione (impatto/rischio), NON severità di difetto.
|
|
6
|
-
> Nessun intervento è un bug attivo → nessun `CRITICAL`.
|
|
7
|
-
> Sequenza consigliata = ordine degli ID.
|
|
8
|
-
|
|
9
|
-
## Quadro sintetico
|
|
10
|
-
|
|
11
|
-
| ID | Intervento | Criticità | Sforzo | Rischio | File toccati | Dipende da |
|
|
12
|
-
|----|-----------|-----------|--------|---------|--------------|-----------|
|
|
13
|
-
| OPT-01 ✅ | `model:` esplicito su ogni agent (→ FTR-007, PR #20) | **HIGH** | Basso | Basso | 15 agent (frontmatter) | — |
|
|
14
|
-
| OPT-02 | Deduplicare CLAUDE.md globale vs progetto | **HIGH** | Minimo | Zero | `~/.claude/CLAUDE.md` | — |
|
|
15
|
-
| OPT-03 | Blocco "Compact instructions" in CLAUDE.md | MEDIUM | Minimo | Zero | `CLAUDE.md` (progetto) | — |
|
|
16
|
-
| OPT-04 | Spostare i cataloghi da CLAUDE.md a reference.md | MEDIUM | Basso | Basso | `CLAUDE.md`, `docs/reference.md` | — |
|
|
17
|
-
| OPT-05 | Procedure on-demand — assessment-manager | MEDIUM | Medio | Basso | `assessment-manager.md` | — |
|
|
18
|
-
| OPT-06 | Procedure on-demand — project-manager | MEDIUM | Medio | Basso | `project-manager.md` | — |
|
|
19
|
-
| OPT-07 | Resource-budget testuale negli assessment agent | MEDIUM | Basso | Basso | 3+ assessment agent | — |
|
|
20
|
-
| OPT-08 | Summary/full split negli output di assessment | **HIGH** | Alto | Medio | assessment agents + int-doc + manager | OPT-07 |
|
|
21
|
-
| OPT-09 | Estrarre calcolo deterministico da assessment-manager | **HIGH** | Alto | Medio | `assessment-manager.md` + nuovo script | — |
|
|
22
|
-
| OPT-10 | generic-software-assessment → ruolo triage | MEDIUM | Medio | Medio | `generic-software-assessment.md` + manager | OPT-08 |
|
|
23
|
-
| OPT-11 | Delegation policy negli orchestratori | LOW | Basso | Basso | 2 orchestratori | — |
|
|
24
|
-
| OPT-12 | Hook di filtro output verboso (opzionale) | LOW | Basso | Basso | `settings.json` + script hook | — |
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Feature atomiche
|
|
29
|
-
|
|
30
|
-
### OPT-01 — `model:` esplicito su ogni agent
|
|
31
|
-
- **Criticità:** HIGH · **Sforzo:** Basso · **Rischio:** Basso
|
|
32
|
-
- **Obiettivo:** ogni agent dichiara il modello adatto al suo ruolo, così nessuno eredita Opus per default.
|
|
33
|
-
- **Scope (solo frontmatter):** aggiungere `model:` ai 15 agent che ne sono privi. Mappatura:
|
|
34
|
-
- `sonnet`: `assessment-manager`, `project-manager`, `developer-backend`, `developer-frontend`, `developer-testing`, `god-class-decomposition`, `domain-model-refactoring`, `dependency-injection-refactoring`, `security-hardening`, `dependency-supply-chain-security`, `define-feature`, `init-agents-md`, `install-toolkit`, `intervention-documentation-standard`
|
|
35
|
-
- `opus`: `review-solution` (unico ruolo con giudizio architetturale profondo)
|
|
36
|
-
- **Fuori scope:** i 7 agent già `haiku` restano invariati.
|
|
37
|
-
- **Criteri di accettazione:** `grep -L "^model:" .claude/agents/*.md` non restituisce nulla; ogni valore ∈ {haiku, sonnet, opus}.
|
|
38
|
-
- **Dipendenze:** nessuna.
|
|
39
|
-
|
|
40
|
-
### OPT-02 — Deduplicare CLAUDE.md globale vs progetto
|
|
41
|
-
- **Criticità:** HIGH · **Sforzo:** Minimo · **Rischio:** Zero
|
|
42
|
-
- **Obiettivo:** eliminare il duplicato caricato a ogni sessione; il `CLAUDE.md` di progetto resta la fonte unica del catalogo toolkit.
|
|
43
|
-
- **Scope:** ridurre `~/.claude/CLAUDE.md` alle sole preferenze cross-progetto (lingua, stile, convenzioni personali). Rimuovere il catalogo toolkit duplicato.
|
|
44
|
-
- **Criteri di accettazione:** `diff ~/.claude/CLAUDE.md ./CLAUDE.md` NON è più identico; il globale non contiene più le tabelle Skills/Commands/Agents; il Gate Protocol resta accessibile (nel progetto).
|
|
45
|
-
- **Dipendenze:** nessuna. ⚠️ File utente globale — confermare con l'utente prima di modificare.
|
|
46
|
-
|
|
47
|
-
### OPT-03 — Blocco "Compact instructions" in CLAUDE.md
|
|
48
|
-
- **Criticità:** MEDIUM · **Sforzo:** Minimo · **Rischio:** Zero
|
|
49
|
-
- **Obiettivo:** guidare l'auto-compaction a preservare ciò che conta (decisioni, file, finding IDs) e scartare rumore.
|
|
50
|
-
- **Scope:** aggiungere al `CLAUDE.md` di progetto una sezione `# Compact instructions` (preserva: obiettivo corrente, decisioni confermate, risposte utente, file modificati, errori aperti, finding/artifact IDs; scarta: risultati grep grezzi, output tool riusciti, spiegazioni ripetute, piani superati).
|
|
51
|
-
- **Criteri di accettazione:** la sezione esiste in `CLAUDE.md`; nessun'altra modifica.
|
|
52
|
-
- **Dipendenze:** nessuna. (Coordinare con OPT-04 se si tocca lo stesso file.)
|
|
53
|
-
|
|
54
|
-
### OPT-04 — Spostare i cataloghi da CLAUDE.md a reference.md
|
|
55
|
-
- **Criticità:** MEDIUM · **Sforzo:** Basso · **Rischio:** Basso
|
|
56
|
-
- **Obiettivo:** CLAUDE.md sotto ~200 righe, solo essenziali operativi; il catalogo di scoperta vive in reference.md.
|
|
57
|
-
- **Scope:** spostare le tabelle Skills / Commands / Agents / Procedures in `docs/reference.md`. In `CLAUDE.md` restano: Dependencies, How it works, override procedure, **Gate Protocol**. Aggiungere in CLAUDE.md un puntatore a reference.md.
|
|
58
|
-
- **Criteri di accettazione:** `CLAUDE.md` < 200 righe; nessuna tabella-catalogo persa (presente in reference.md); Gate Protocol ancora in CLAUDE.md.
|
|
59
|
-
- **Dipendenze:** nessuna.
|
|
60
|
-
|
|
61
|
-
### OPT-05 — Procedure on-demand (assessment-manager)
|
|
62
|
-
- **Criticità:** MEDIUM · **Sforzo:** Medio · **Rischio:** Basso
|
|
63
|
-
- **Obiettivo:** leggere ogni procedura nella fase in cui serve, non tutte all'avvio.
|
|
64
|
-
- **Scope:** in `assessment-manager.md`, spostare le istruzioni "read at startup" così che `token-estimation.md`/`pricing.md` si leggano in Phase 3-4, `assessment-findings-gate.md` in Phase 5, ecc.
|
|
65
|
-
- **Criteri di accettazione:** nessuna procedura letta prima della fase che la usa; il comportamento delle fasi resta invariato.
|
|
66
|
-
- **Dipendenze:** nessuna. (Se si fa OPT-09, `token-estimation.md` sparisce del tutto dal contesto LLM.)
|
|
67
|
-
|
|
68
|
-
### OPT-06 — Procedure on-demand (project-manager)
|
|
69
|
-
- **Criticità:** MEDIUM · **Sforzo:** Medio · **Rischio:** Basso
|
|
70
|
-
- **Obiettivo:** stesso principio di OPT-05 applicato al `project-manager` (che legge ancora più procedure).
|
|
71
|
-
- **Scope:** riorganizzare `project-manager.md` per caricare ogni procedura alla fase pertinente (PR-procedure alla fase PR, ecc.).
|
|
72
|
-
- **Criteri di accettazione:** nessuna procedura caricata anticipatamente; pipeline invariata.
|
|
73
|
-
- **Dipendenze:** nessuna.
|
|
74
|
-
|
|
75
|
-
### OPT-07 — Resource-budget testuale negli assessment agent
|
|
76
|
-
- **Criticità:** MEDIUM · **Sforzo:** Basso · **Rischio:** Basso
|
|
77
|
-
- **Obiettivo:** limiti di lettura/output come **istruzioni nel prompt** (NON frontmatter — `budget:` non è supportato dall'harness).
|
|
78
|
-
- **Scope:** aggiungere una sezione `## Resource budget` in prosa agli assessment agent (es. "Inspect at most ~25 files; ≤2 evidence snippets per finding; stop when evidence is sufficient — do not explore to increase coverage").
|
|
79
|
-
- **Criteri di accettazione:** la sezione è testo del prompt, non YAML; presente in `generic-software-assessment`, `layered-architecture-assessment`, `concurrency-safety-assessment`.
|
|
80
|
-
- **Dipendenze:** nessuna.
|
|
81
|
-
|
|
82
|
-
### OPT-08 — Summary/full split negli output di assessment
|
|
83
|
-
- **Criticità:** HIGH · **Sforzo:** Alto · **Rischio:** Medio
|
|
84
|
-
- **Obiettivo:** l'orchestratore legge un **summary compatto**, non il documento intero. Il guadagno reale (non il formato JSON in sé).
|
|
85
|
-
- **Scope:** ogni assessment agent produce `{PREFIX}-{area}.full.md` (dettagli) + `{PREFIX}-{area}.summary` (conteggi per severità, finding IDs, path del full). `assessment-manager` e `intervention-documentation-standard` consumano il summary; il full si apre solo per i finding accettati.
|
|
86
|
-
- **Criteri di accettazione:** ogni agent scrive entrambi i file; il manager non legge i `.full` in Phase 3; l'int-doc apre i full solo per i finding selezionati.
|
|
87
|
-
- **Dipendenze:** OPT-07 (budget di output coerente).
|
|
88
|
-
|
|
89
|
-
### OPT-09 — Estrarre calcolo deterministico da assessment-manager
|
|
90
|
-
- **Criticità:** HIGH · **Sforzo:** Alto · **Rischio:** Medio
|
|
91
|
-
- **Obiettivo:** togliere dal prompt ~3.500 parole di pseudo-codice (calcolo token/effort, registry write) che un LLM esegue in modo costoso e fragile.
|
|
92
|
-
- **Scope:** spostare Token-Estimate / Effort-Estimate / registry-write in uno script (`scripts/estimate.*`) invocato via Bash; il prompt dell'agent si riduce a "esegui lo script con questi input e verifica l'output".
|
|
93
|
-
- **Criteri di accettazione:** i tre file prodotti sono identici (per input noti) a prima; `assessment-manager.md` perde le sezioni di calcolo passo-passo; nessun errore aritmetico possibile lato LLM.
|
|
94
|
-
- **Dipendenze:** nessuna hard; sinergica con OPT-05.
|
|
95
|
-
|
|
96
|
-
### OPT-10 — generic-software-assessment → ruolo triage
|
|
97
|
-
- **Criticità:** MEDIUM · **Sforzo:** Medio · **Rischio:** Medio
|
|
98
|
-
- **Obiettivo:** evitare che il generic duplichi il lavoro degli specialisti (layer, security, concurrency) sullo stesso codebase.
|
|
99
|
-
- **Scope:** trasformare `generic-software-assessment` in triage: scansione superficiale che decide quali specialisti attivare e copre solo le aree non coperte (Opzione A della gap analysis).
|
|
100
|
-
- **Criteri di accettazione:** il generic non riesamina aree già coperte da uno specialista attivo; l'output indica gli specialisti raccomandati.
|
|
101
|
-
- **Dipendenze:** meglio dopo OPT-08 (contratto di output stabile).
|
|
102
|
-
|
|
103
|
-
### OPT-11 — Delegation policy negli orchestratori
|
|
104
|
-
- **Criticità:** LOW · **Sforzo:** Basso · **Rischio:** Basso
|
|
105
|
-
- **Obiettivo:** regola esplicita "delega un subagent solo se serve" (task indipendente/parallelo, output voluminoso, riuso multiplo); non delegare ciò che una grep/read risolve.
|
|
106
|
-
- **Scope:** aggiungere una sezione `## Delegation policy` a `assessment-manager` e `project-manager`.
|
|
107
|
-
- **Criteri di accettazione:** entrambi gli orchestratori contengono i criteri delega/non-delega.
|
|
108
|
-
- **Dipendenze:** nessuna.
|
|
109
|
-
|
|
110
|
-
### OPT-12 — Hook di filtro output verboso (opzionale)
|
|
111
|
-
- **Criticità:** LOW · **Sforzo:** Basso · **Rischio:** Basso
|
|
112
|
-
- **Obiettivo:** filtrare output verbosi (build/test/log) prima che entrino nel contesto.
|
|
113
|
-
- **Scope:** `PreToolUse` su Bash in `settings.json` + script che riduce l'output di test/build alle sole righe rilevanti.
|
|
114
|
-
- **Criteri di accettazione:** l'hook appare in `/hooks`; un comando di test mostra solo failures.
|
|
115
|
-
- **Dipendenze:** nessuna. Fare solo se gli assessment inizieranno a lanciare build/test.
|