@delfini/cli 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1277 @@
1
+ import {
2
+ ZodError,
3
+ analysisSchema,
4
+ buildPromptWithDrops,
5
+ classifyEntry,
6
+ estimatePromptTokens,
7
+ external_exports,
8
+ filterDiff,
9
+ init_esm_shims,
10
+ normalizeDocScope,
11
+ validateAndReconcile,
12
+ validateDocScopeEntry
13
+ } from "./chunk-MUW24ZC4.js";
14
+
15
+ // src/cli.ts
16
+ init_esm_shims();
17
+ import { readFileSync as readFileSync4 } from "fs";
18
+ import path4 from "path";
19
+ import { fileURLToPath as fileURLToPath3 } from "url";
20
+ import { Command, CommanderError } from "commander";
21
+
22
+ // src/commands/diff-status.ts
23
+ init_esm_shims();
24
+ import simpleGit2 from "simple-git";
25
+
26
+ // src/git.ts
27
+ init_esm_shims();
28
+ import simpleGit from "simple-git";
29
+ var RepoRootNotFoundError = class extends Error {
30
+ code = "REPO_ROOT_NOT_FOUND";
31
+ constructor(cause) {
32
+ super("not inside a git repository \u2014 Delfini requires a git checkout");
33
+ this.name = "RepoRootNotFoundError";
34
+ if (cause !== void 0) {
35
+ ;
36
+ this.cause = cause;
37
+ }
38
+ }
39
+ };
40
+ async function getRepoRoot(cwd) {
41
+ const baseDir = cwd ?? process.cwd();
42
+ const git = simpleGit({ baseDir });
43
+ try {
44
+ const raw = await git.revparse(["--show-toplevel"]);
45
+ const trimmed = raw.trim();
46
+ if (trimmed.length === 0) {
47
+ throw new RepoRootNotFoundError();
48
+ }
49
+ return trimmed;
50
+ } catch (err) {
51
+ if (err instanceof RepoRootNotFoundError) throw err;
52
+ throw new RepoRootNotFoundError(err);
53
+ }
54
+ }
55
+ async function getCurrentBranch(git) {
56
+ const raw = await git.revparse(["--abbrev-ref", "HEAD"]);
57
+ return raw.trim();
58
+ }
59
+ async function getDefaultBranch(git) {
60
+ try {
61
+ const raw = await git.raw(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
62
+ const trimmed = raw.trim();
63
+ if (trimmed.startsWith("origin/")) {
64
+ return trimmed.slice("origin/".length);
65
+ }
66
+ if (trimmed.length > 0) {
67
+ return trimmed;
68
+ }
69
+ } catch {
70
+ }
71
+ for (const name of ["main", "master"]) {
72
+ try {
73
+ await git.raw(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`]);
74
+ return name;
75
+ } catch {
76
+ }
77
+ }
78
+ return "main";
79
+ }
80
+ var TRACE_DIR_PREFIX = ".delfini-trace/";
81
+ async function listUntrackedFiles(git) {
82
+ const raw = await git.raw(["ls-files", "--others", "--exclude-standard", "-z"]);
83
+ return raw.split("\0").filter((line) => line.length > 0).filter((line) => !line.startsWith(TRACE_DIR_PREFIX));
84
+ }
85
+ async function hasUncommittedChanges(git) {
86
+ const diff = await git.diff(["HEAD"]);
87
+ if (diff.trim().length > 0) {
88
+ return true;
89
+ }
90
+ const untracked = await listUntrackedFiles(git);
91
+ return untracked.length > 0;
92
+ }
93
+ async function hasCommittedChangesAgainst(git, baseRef) {
94
+ const diff = await git.diff([baseRef, "HEAD"]);
95
+ return diff.trim().length > 0;
96
+ }
97
+ async function resolveBaseRef(git, explicitBase, stderr) {
98
+ if (explicitBase !== void 0 && explicitBase.length > 0) {
99
+ return explicitBase;
100
+ }
101
+ try {
102
+ const raw = await git.raw(["merge-base", "HEAD", "origin/main"]);
103
+ const trimmed = raw.trim();
104
+ if (trimmed.length === 0) {
105
+ stderr.write(
106
+ "\u26A0\uFE0F Could not resolve `git merge-base HEAD origin/main` \u2014 diff base falling back to HEAD (empty diff).\n"
107
+ );
108
+ return "HEAD";
109
+ }
110
+ return trimmed;
111
+ } catch {
112
+ stderr.write(
113
+ "\u26A0\uFE0F Could not resolve `git merge-base HEAD origin/main` \u2014 diff base falling back to HEAD (empty diff).\n"
114
+ );
115
+ return "HEAD";
116
+ }
117
+ }
118
+
119
+ // src/commands/diff-status.ts
120
+ async function runDiffStatus(options = {}) {
121
+ const stdout = options.stdout ?? process.stdout;
122
+ const stderr = options.stderr ?? process.stderr;
123
+ let repoRoot;
124
+ try {
125
+ repoRoot = options.repoRoot ?? await getRepoRoot();
126
+ } catch (err) {
127
+ if (err instanceof RepoRootNotFoundError) {
128
+ stderr.write(`${err.message}
129
+ `);
130
+ } else {
131
+ stderr.write(`diff-status failed: ${describeError(err)}
132
+ `);
133
+ }
134
+ return 1;
135
+ }
136
+ try {
137
+ const git = simpleGit2({ baseDir: repoRoot });
138
+ const baseRef = await resolveBaseRef(git, options.base, stderr);
139
+ const branch = await getCurrentBranch(git);
140
+ const defaultBranch = await getDefaultBranch(git);
141
+ const isDefaultBranch = branch !== "HEAD" && branch === defaultBranch;
142
+ const hasLocalChanges = await hasUncommittedChanges(git);
143
+ const hasCommittedChanges = await hasCommittedChangesAgainst(git, baseRef);
144
+ const status = {
145
+ branch,
146
+ isDefaultBranch,
147
+ hasLocalChanges,
148
+ hasCommittedChanges
149
+ };
150
+ stdout.write(`${JSON.stringify(status)}
151
+ `);
152
+ return 0;
153
+ } catch (err) {
154
+ stderr.write(`diff-status failed: ${describeError(err)}
155
+ `);
156
+ return 1;
157
+ }
158
+ }
159
+ function describeError(err) {
160
+ if (err instanceof Error) return err.message;
161
+ return String(err);
162
+ }
163
+
164
+ // src/commands/install.ts
165
+ init_esm_shims();
166
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
167
+ import { dirname, join as join2, resolve } from "path";
168
+ import { createInterface } from "readline/promises";
169
+ import { fileURLToPath } from "url";
170
+
171
+ // src/trace.ts
172
+ init_esm_shims();
173
+ import {
174
+ appendFileSync,
175
+ existsSync,
176
+ mkdirSync,
177
+ readFileSync,
178
+ statSync,
179
+ writeFileSync
180
+ } from "fs";
181
+ import { join } from "path";
182
+ var TRACE_DIR_NAME = ".delfini-trace";
183
+ var GITIGNORE_LINE = ".delfini-trace/";
184
+ function ensureTraceDir(repoRoot) {
185
+ const tracePath = join(repoRoot, TRACE_DIR_NAME);
186
+ if (existsSync(tracePath)) {
187
+ const stat = statSync(tracePath);
188
+ if (!stat.isDirectory()) {
189
+ throw new Error(
190
+ `ensureTraceDir: ${tracePath} exists but is not a directory. Remove or rename it so the CLI can create the trace directory.`
191
+ );
192
+ }
193
+ return tracePath;
194
+ }
195
+ mkdirSync(tracePath, { recursive: true });
196
+ return tracePath;
197
+ }
198
+ function appendToGitignore(repoRoot) {
199
+ const gitignorePath = join(repoRoot, ".gitignore");
200
+ const existed = existsSync(gitignorePath);
201
+ const existingContent = existed ? readFileSync(gitignorePath, "utf8") : "";
202
+ const lines = existingContent.split(/\r?\n/);
203
+ for (const line of lines) {
204
+ if (line.trim() === GITIGNORE_LINE) {
205
+ return { changed: false };
206
+ }
207
+ }
208
+ const useCRLF = existingContent.includes("\r\n");
209
+ const newline = useCRLF ? "\r\n" : "\n";
210
+ if (!existed || existingContent.length === 0) {
211
+ writeFileSync(gitignorePath, `${GITIGNORE_LINE}${newline}`);
212
+ return { changed: true };
213
+ }
214
+ const endsWithNewline = existingContent.endsWith("\n");
215
+ const prefix = endsWithNewline ? "" : newline;
216
+ appendFileSync(gitignorePath, `${prefix}${GITIGNORE_LINE}${newline}`);
217
+ return { changed: true };
218
+ }
219
+ function writeTraceFile(repoRoot, filename, content) {
220
+ validateBasename(filename);
221
+ const traceDir = ensureTraceDir(repoRoot);
222
+ const target = join(traceDir, filename);
223
+ writeFileSync(target, content);
224
+ return target;
225
+ }
226
+ function writeRetryAttemptFile(repoRoot, attemptNumber, content) {
227
+ if (attemptNumber !== 1 && attemptNumber !== 2) {
228
+ throw new Error(
229
+ `writeRetryAttemptFile: attemptNumber must be 1 or 2, got ${String(attemptNumber)}`
230
+ );
231
+ }
232
+ return writeTraceFile(repoRoot, `findings-attempt-${attemptNumber}.json`, content);
233
+ }
234
+ function validateBasename(filename) {
235
+ if (filename.length === 0) {
236
+ throw new Error("writeTraceFile: filename must not be empty");
237
+ }
238
+ if (filename === "." || filename === "..") {
239
+ throw new Error(`writeTraceFile: filename must not be "${filename}"`);
240
+ }
241
+ if (filename.includes("/") || filename.includes("\\")) {
242
+ throw new Error(
243
+ `writeTraceFile: filename must be a basename without path separators, got "${filename}"`
244
+ );
245
+ }
246
+ }
247
+
248
+ // src/commands/install.ts
249
+ var InstallToolNotSupportedError = class extends Error {
250
+ code = "INSTALL_TOOL_NOT_SUPPORTED";
251
+ constructor(tool) {
252
+ super(
253
+ `delfini install: --tool '${tool}' is not supported. The Skill is Claude-only in V1 (design-spec NG2 / project-context "Skill \u2014 Out of Scope in V1 / V1.1: No multi-LLM-provider support \u2014 Claude-only by design"). Use --tool CLAUDE (the default).`
254
+ );
255
+ this.name = "InstallToolNotSupportedError";
256
+ }
257
+ };
258
+ var CLAUDE_MD_OPEN_MARKER = "<!-- delfini:auto-invoke-block-v1 -->";
259
+ var CLAUDE_MD_CLOSE_MARKER = "<!-- /delfini:auto-invoke-block-v1 -->";
260
+ var SKILL_RELATIVE_PATH = ".claude/skills/delfini/SKILL.md";
261
+ var CLAUDE_MD_FILENAME = "CLAUDE.md";
262
+ var SUPPORTED_TOOL = "CLAUDE";
263
+ var TEMPLATES_DIR = resolveTemplatesDir();
264
+ function resolveTemplatesDir() {
265
+ const here = dirname(fileURLToPath(import.meta.url));
266
+ return resolve(here, "..", "..", "templates");
267
+ }
268
+ async function runInstall(targetPath, options) {
269
+ const tool = options?.tool ?? SUPPORTED_TOOL;
270
+ const logger = options?.logger ?? console;
271
+ if (tool !== SUPPORTED_TOOL) {
272
+ throw new InstallToolNotSupportedError(tool);
273
+ }
274
+ const resolvedTarget = resolve(process.cwd(), targetPath);
275
+ const repoRoot = await getRepoRoot(resolvedTarget);
276
+ writeSkillTemplate(repoRoot, logger);
277
+ await applyAutoInvokeDecision(repoRoot, logger, options?.confirmAutoInvoke);
278
+ appendGitignoreLine(repoRoot, logger);
279
+ }
280
+ function parseYesNo(answer) {
281
+ const normalised = answer.trim().toLowerCase();
282
+ return normalised === "y" || normalised === "yes";
283
+ }
284
+ async function applyAutoInvokeDecision(repoRoot, logger, confirmAutoInvoke) {
285
+ const decision = await resolveAutoInvoke(confirmAutoInvoke);
286
+ if (decision === "yes") {
287
+ appendClaudeMdBlock(repoRoot, logger);
288
+ } else if (decision === "no") {
289
+ stripClaudeMdBlock(repoRoot, logger);
290
+ } else {
291
+ const target = join2(repoRoot, CLAUDE_MD_FILENAME);
292
+ log(logger, `CLAUDE.md \u2192 ${target} (non-interactive shell: auto-invoke prompt skipped, no change)`);
293
+ }
294
+ }
295
+ async function resolveAutoInvoke(confirmAutoInvoke) {
296
+ if (confirmAutoInvoke) {
297
+ return await confirmAutoInvoke() ? "yes" : "no";
298
+ }
299
+ if (!process.stdin.isTTY) {
300
+ return "skip";
301
+ }
302
+ return await promptAutoInvoke() ? "yes" : "no";
303
+ }
304
+ async function promptAutoInvoke() {
305
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
306
+ try {
307
+ const answer = await rl.question("Auto-invoke /delfini on PR creation? (y/n) ");
308
+ return parseYesNo(answer);
309
+ } finally {
310
+ rl.close();
311
+ }
312
+ }
313
+ function writeSkillTemplate(repoRoot, logger) {
314
+ const target = join2(repoRoot, SKILL_RELATIVE_PATH);
315
+ const templateSource = join2(TEMPLATES_DIR, "SKILL.md");
316
+ const content = readFileSync2(templateSource);
317
+ mkdirSync2(dirname(target), { recursive: true });
318
+ writeFileSync2(target, content);
319
+ log(logger, `SKILL.md \u2192 ${target}`);
320
+ }
321
+ function appendClaudeMdBlock(repoRoot, logger) {
322
+ const target = join2(repoRoot, CLAUDE_MD_FILENAME);
323
+ const blockSource = join2(TEMPLATES_DIR, "claude-md-append-block.txt");
324
+ const body = readFileSync2(blockSource, "utf8").replace(/\s+$/u, "");
325
+ const block = `${CLAUDE_MD_OPEN_MARKER}
326
+ ${body}
327
+ ${CLAUDE_MD_CLOSE_MARKER}
328
+ `;
329
+ if (!existsSync2(target)) {
330
+ writeFileSync2(target, block);
331
+ log(logger, `CLAUDE.md \u2192 ${target} (created)`);
332
+ return;
333
+ }
334
+ const existing = readFileSync2(target, "utf8");
335
+ if (existing.includes(CLAUDE_MD_OPEN_MARKER)) {
336
+ log(logger, `CLAUDE.md \u2192 ${target} (block already present, no change)`);
337
+ return;
338
+ }
339
+ if (existing.length === 0) {
340
+ writeFileSync2(target, block);
341
+ log(logger, `CLAUDE.md \u2192 ${target} (block appended)`);
342
+ return;
343
+ }
344
+ const needsLeadingNewline = !existing.endsWith("\n");
345
+ const prefix = needsLeadingNewline ? "\n" : "";
346
+ writeFileSync2(target, `${existing}${prefix}${block}`);
347
+ log(logger, `CLAUDE.md \u2192 ${target} (block appended)`);
348
+ }
349
+ function stripClaudeMdBlock(repoRoot, logger) {
350
+ const target = join2(repoRoot, CLAUDE_MD_FILENAME);
351
+ if (!existsSync2(target)) {
352
+ log(logger, `CLAUDE.md \u2192 ${target} (no block to remove, no change)`);
353
+ return;
354
+ }
355
+ const existing = readFileSync2(target, "utf8");
356
+ const openIdx = existing.indexOf(CLAUDE_MD_OPEN_MARKER);
357
+ if (openIdx === -1) {
358
+ log(logger, `CLAUDE.md \u2192 ${target} (no block to remove, no change)`);
359
+ return;
360
+ }
361
+ const afterOpen = openIdx + CLAUDE_MD_OPEN_MARKER.length;
362
+ const closeIdx = existing.indexOf(CLAUDE_MD_CLOSE_MARKER, afterOpen);
363
+ let endIdx;
364
+ if (closeIdx === -1) {
365
+ endIdx = existing.length;
366
+ } else {
367
+ endIdx = closeIdx + CLAUDE_MD_CLOSE_MARKER.length;
368
+ if (existing.startsWith("\r\n", endIdx)) {
369
+ endIdx += 2;
370
+ } else if (existing[endIdx] === "\n") {
371
+ endIdx += 1;
372
+ }
373
+ }
374
+ const result = existing.slice(0, openIdx) + existing.slice(endIdx);
375
+ writeFileSync2(target, result);
376
+ log(logger, `CLAUDE.md \u2192 ${target} (block removed)`);
377
+ }
378
+ function appendGitignoreLine(repoRoot, logger) {
379
+ const target = join2(repoRoot, ".gitignore");
380
+ const { changed } = appendToGitignore(repoRoot);
381
+ if (changed) {
382
+ log(logger, `.gitignore \u2192 ${target} (appended .delfini-trace/)`);
383
+ } else {
384
+ log(logger, `.gitignore \u2192 ${target} (.delfini-trace/ already present, no change)`);
385
+ }
386
+ }
387
+ function log(logger, message) {
388
+ if (typeof logger.log === "function") {
389
+ logger.log(message);
390
+ }
391
+ }
392
+
393
+ // src/commands/local-finalize.ts
394
+ init_esm_shims();
395
+ import { promises as fs } from "fs";
396
+ import path from "path";
397
+ var TRACE_DIR_RELATIVE = ".delfini-trace";
398
+ var ANALYSIS_INPUT_FILENAME = "analysis-input.json";
399
+ var REPORT_FILENAME = "report.md";
400
+ var SEVERITY_ICON = {
401
+ High: "[H]",
402
+ Medium: "[M]",
403
+ Low: "[L]"
404
+ };
405
+ var ClarifyingQuestionSchema = external_exports.object({
406
+ whatChanged: external_exports.string().min(1),
407
+ naturalHomeDoc: external_exports.string().min(1),
408
+ naturalHomeSection: external_exports.string().min(1),
409
+ question: external_exports.string().min(1),
410
+ proposedReplacement: external_exports.string().nullable()
411
+ });
412
+ var ClarifyingQuestionsArraySchema = external_exports.array(ClarifyingQuestionSchema);
413
+ async function runLocalFinalize(options) {
414
+ const stderr = options.stderr ?? process.stderr;
415
+ const stdout = options.stdout ?? process.stdout;
416
+ const repoRoot = options.repoRoot ?? await getRepoRoot();
417
+ const findingsPath = path.isAbsolute(options.findingsPath) ? options.findingsPath : path.join(repoRoot, options.findingsPath);
418
+ let findingsContent;
419
+ try {
420
+ findingsContent = await fs.readFile(findingsPath, "utf8");
421
+ } catch (err) {
422
+ return emitSchemaValidationError(stderr, [
423
+ {
424
+ path: "findings.json",
425
+ message: `Failed to read findings file at "${findingsPath}": ${formatErrorMessage(err)}`
426
+ }
427
+ ]);
428
+ }
429
+ let rawJson;
430
+ try {
431
+ rawJson = JSON.parse(findingsContent);
432
+ } catch (err) {
433
+ return emitSchemaValidationError(stderr, [
434
+ {
435
+ path: "findings.json",
436
+ message: `Failed to parse findings.json as JSON: ${formatErrorMessage(err)}`
437
+ }
438
+ ]);
439
+ }
440
+ const analysisInputPath = path.join(repoRoot, TRACE_DIR_RELATIVE, ANALYSIS_INPUT_FILENAME);
441
+ let docs;
442
+ try {
443
+ const raw = await fs.readFile(analysisInputPath, "utf8");
444
+ const parsed = JSON.parse(raw);
445
+ if (!Array.isArray(parsed.docs)) {
446
+ return emitSchemaValidationError(stderr, [
447
+ {
448
+ path: "analysis-input.json",
449
+ message: `analysis-input.json is missing the "docs" array. Re-run \`delfini local-prepare\`.`
450
+ }
451
+ ]);
452
+ }
453
+ docs = parsed.docs;
454
+ } catch (err) {
455
+ return emitSchemaValidationError(stderr, [
456
+ {
457
+ path: "analysis-input.json",
458
+ message: `Failed to read analysis-input.json at "${analysisInputPath}": ${formatErrorMessage(err)}. Run \`delfini local-prepare\` first.`
459
+ }
460
+ ]);
461
+ }
462
+ let clarifications;
463
+ try {
464
+ const clarifyingQuestionsRaw = extractClarifyingQuestionsField(rawJson);
465
+ clarifications = ClarifyingQuestionsArraySchema.parse(clarifyingQuestionsRaw);
466
+ } catch (err) {
467
+ if (err instanceof ZodError) {
468
+ return emitSchemaValidationError(stderr, formatZodIssues(err, "clarifyingQuestions"));
469
+ }
470
+ throw err;
471
+ }
472
+ let result;
473
+ try {
474
+ result = validateAndReconcile(rawJson, docs, (message) => {
475
+ stderr.write(`\u26A0\uFE0F ${message}
476
+ `);
477
+ });
478
+ } catch (err) {
479
+ if (err instanceof ZodError) {
480
+ return emitSchemaValidationError(stderr, formatZodIssues(err));
481
+ }
482
+ throw err;
483
+ }
484
+ const report = renderReport(result, clarifications);
485
+ writeTraceFile(repoRoot, REPORT_FILENAME, report);
486
+ stdout.write(report.endsWith("\n") ? report : `${report}
487
+ `);
488
+ const hasApplyEligible = result.contradictions.length > 0 || result.additions.length > 0;
489
+ const hasNarrativeOnly = (result.narrativeOnlyContradictions ?? []).length > 0;
490
+ return hasApplyEligible || hasNarrativeOnly ? 1 : 0;
491
+ }
492
+ function emitSchemaValidationError(stderr, issues) {
493
+ const payload = { error: "schema_validation", issues };
494
+ stderr.write(`${JSON.stringify(payload, null, 2)}
495
+ `);
496
+ return 3;
497
+ }
498
+ function formatZodIssues(err, prefix) {
499
+ return err.issues.map((issue) => ({
500
+ path: [prefix, ...issue.path].filter((p) => p !== void 0 && p !== "").map(String).join("."),
501
+ message: issue.message
502
+ }));
503
+ }
504
+ function formatErrorMessage(err) {
505
+ if (err instanceof Error) return err.message;
506
+ return String(err);
507
+ }
508
+ function extractClarifyingQuestionsField(rawJson) {
509
+ if (typeof rawJson !== "object" || rawJson === null) {
510
+ return [];
511
+ }
512
+ if (!("clarifyingQuestions" in rawJson)) {
513
+ return [];
514
+ }
515
+ const value = rawJson.clarifyingQuestions;
516
+ return value ?? [];
517
+ }
518
+ function renderReport(result, clarifications) {
519
+ const narrativeOnly = result.narrativeOnlyContradictions ?? [];
520
+ const applyEligibleDriftCount = result.contradictions.length;
521
+ const narrativeOnlyCount = narrativeOnly.length;
522
+ const driftCount = applyEligibleDriftCount + narrativeOnlyCount;
523
+ const additiveCount = result.additions.length;
524
+ const clarificationCount = clarifications.length;
525
+ const parts = [];
526
+ parts.push("# Delfini drift analysis");
527
+ parts.push("");
528
+ parts.push(
529
+ `${driftCount} drift, ${additiveCount} additive, ${clarificationCount} clarification finding(s).`
530
+ );
531
+ parts.push("");
532
+ if (applyEligibleDriftCount === 0 && additiveCount === 0) {
533
+ parts.push("No apply-eligible findings.");
534
+ parts.push("");
535
+ } else {
536
+ parts.push("## Apply-eligible findings");
537
+ parts.push("");
538
+ let index = 1;
539
+ for (const drift of result.contradictions) {
540
+ parts.push(renderDriftFinding(drift, index));
541
+ parts.push("");
542
+ index += 1;
543
+ }
544
+ for (const additive of result.additions) {
545
+ parts.push(renderAdditiveFinding(additive, index));
546
+ parts.push("");
547
+ index += 1;
548
+ }
549
+ }
550
+ if (narrativeOnlyCount > 0 || clarificationCount > 0) {
551
+ parts.push("## Manual review required");
552
+ parts.push("");
553
+ for (const drift of narrativeOnly) {
554
+ parts.push(renderNarrativeOnlyDrift(drift));
555
+ parts.push("");
556
+ }
557
+ for (const clarification of clarifications) {
558
+ parts.push(renderClarification(clarification));
559
+ parts.push("");
560
+ }
561
+ }
562
+ return parts.join("\n");
563
+ }
564
+ function renderDriftFinding(c, index) {
565
+ const lines = [];
566
+ const icon = SEVERITY_ICON[c.severity];
567
+ lines.push(
568
+ `### [${index}] ${icon} drift: ${c.targetDocPath}:${c.targetLineStart}-${c.targetLineEnd}`
569
+ );
570
+ lines.push("");
571
+ lines.push(`**Section:** ${c.targetSection}`);
572
+ lines.push(`**Severity:** ${c.severity} **Confidence:** ${c.confidence}/5`);
573
+ lines.push("");
574
+ lines.push(`**What changed:** ${c.whatChanged}`);
575
+ lines.push(`**What contradicts:** ${c.whatContradicts}`);
576
+ lines.push("");
577
+ lines.push("**Proposed change:**");
578
+ lines.push("```diff");
579
+ for (const line of c.quotedDocText.replace(/\r\n/g, "\n").split("\n")) {
580
+ lines.push(`- ${line}`);
581
+ }
582
+ const replacement = c.proposedReplacement ?? "<empty>";
583
+ for (const line of replacement.replace(/\r\n/g, "\n").split("\n")) {
584
+ lines.push(`+ ${line}`);
585
+ }
586
+ lines.push("```");
587
+ return lines.join("\n");
588
+ }
589
+ function renderAdditiveFinding(a, index) {
590
+ const lines = [];
591
+ const icon = SEVERITY_ICON[a.severity];
592
+ lines.push(
593
+ `### [${index}] ${icon} additive: ${a.targetDocPath} \u2014 insert ${a.insertionMode} line ${a.anchorLine}`
594
+ );
595
+ lines.push("");
596
+ lines.push(`**Anchor section:** ${a.anchorSection}`);
597
+ lines.push(`**Severity:** ${a.severity} **Confidence:** ${a.confidence}/5`);
598
+ lines.push("");
599
+ lines.push(`**What changed:** ${a.whatChanged}`);
600
+ lines.push(`**Rationale:** ${a.rationaleForAddition}`);
601
+ lines.push("");
602
+ lines.push(`**Proposed addition (insert ${a.insertionMode} line ${a.anchorLine}):**`);
603
+ lines.push("```diff");
604
+ for (const line of a.proposedContent.replace(/\r\n/g, "\n").split("\n")) {
605
+ lines.push(`+ ${line}`);
606
+ }
607
+ lines.push("```");
608
+ return lines.join("\n");
609
+ }
610
+ function renderNarrativeOnlyDrift(c) {
611
+ const lines = [];
612
+ const icon = SEVERITY_ICON[c.severity];
613
+ lines.push(
614
+ `### ${icon} narrative-only drift: ${c.targetDocPath}:${c.targetLineStart}-${c.targetLineEnd}`
615
+ );
616
+ lines.push("");
617
+ lines.push(`**Section:** ${c.targetSection}`);
618
+ lines.push(`**Severity:** ${c.severity} **Confidence:** ${c.confidence}/5`);
619
+ lines.push("");
620
+ lines.push(`**What changed:** ${c.whatChanged}`);
621
+ lines.push(`**What contradicts:** ${c.whatContradicts}`);
622
+ lines.push("");
623
+ lines.push("**Quoted doc text:**");
624
+ lines.push("```");
625
+ lines.push(c.quotedDocText);
626
+ lines.push("```");
627
+ lines.push("");
628
+ lines.push(
629
+ "**Resolution:** The doc rule above is correct; the PR code violates it. Fix the code (or hand-edit the doc if the rule itself needs to change) \u2014 no auto-apply available."
630
+ );
631
+ return lines.join("\n");
632
+ }
633
+ function renderClarification(q) {
634
+ const lines = [];
635
+ lines.push(`### Clarification: ${q.naturalHomeDoc} \u2014 ${q.naturalHomeSection}`);
636
+ lines.push("");
637
+ lines.push(`**What changed:** ${q.whatChanged}`);
638
+ lines.push(`**Question:** ${q.question}`);
639
+ if (q.proposedReplacement !== null) {
640
+ lines.push("");
641
+ lines.push("**Suggested replacement (optional):**");
642
+ lines.push("```");
643
+ lines.push(q.proposedReplacement);
644
+ lines.push("```");
645
+ }
646
+ return lines.join("\n");
647
+ }
648
+
649
+ // src/commands/local-prepare.ts
650
+ init_esm_shims();
651
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
652
+ import { promises as fs3 } from "fs";
653
+ import { execFile } from "child_process";
654
+ import path3 from "path";
655
+ import { fileURLToPath as fileURLToPath2 } from "url";
656
+ import { promisify } from "util";
657
+ import simpleGit3 from "simple-git";
658
+
659
+ // src/doc-scope.ts
660
+ init_esm_shims();
661
+ import { promises as fs2 } from "fs";
662
+ import path2 from "path";
663
+ import { glob } from "tinyglobby";
664
+ var DOC_SCOPE_RELATIVE_PATH = ".claude/skills/delfini/doc-scope.json";
665
+ var DOC_SCOPE_VERSION = 1;
666
+ var DOC_SCOPE_VERSION_MISMATCH_MESSAGE = "your doc-scope.json is for a newer @delfini/cli; please upgrade.";
667
+ var REPO_ROOT_REL = ".";
668
+ var DocScopeVersionMismatchError = class extends Error {
669
+ code = "DOC_SCOPE_VERSION_MISMATCH";
670
+ constructor(message = DOC_SCOPE_VERSION_MISMATCH_MESSAGE) {
671
+ super(message);
672
+ this.name = "DocScopeVersionMismatchError";
673
+ }
674
+ };
675
+ var DocScopeCorruptError = class extends Error {
676
+ code = "DOC_SCOPE_CORRUPT";
677
+ constructor(message) {
678
+ super(message);
679
+ this.name = "DocScopeCorruptError";
680
+ }
681
+ };
682
+ var DocScopeValidationError = class extends Error {
683
+ code = "DOC_SCOPE_VALIDATION";
684
+ constructor(message) {
685
+ super(message);
686
+ this.name = "DocScopeValidationError";
687
+ }
688
+ };
689
+ var docScopeSchemaV1 = external_exports.object({
690
+ version: external_exports.literal(1),
691
+ doc_scope: external_exports.array(external_exports.string().min(1))
692
+ });
693
+ var versionProbeSchema = external_exports.object({
694
+ version: external_exports.number().int().positive()
695
+ });
696
+ async function readDocScope(repoRoot) {
697
+ const root = repoRoot ?? await getRepoRoot();
698
+ const target = path2.join(root, DOC_SCOPE_RELATIVE_PATH);
699
+ let raw;
700
+ try {
701
+ raw = await fs2.readFile(target, "utf8");
702
+ } catch (err) {
703
+ if (isNoEntError(err)) return null;
704
+ throw err;
705
+ }
706
+ let parsed;
707
+ try {
708
+ parsed = JSON.parse(raw);
709
+ } catch (err) {
710
+ throw new DocScopeCorruptError(
711
+ `${DOC_SCOPE_RELATIVE_PATH} is malformed: ${err.message}`
712
+ );
713
+ }
714
+ const probe = versionProbeSchema.safeParse(parsed);
715
+ if (probe.success && probe.data.version > DOC_SCOPE_VERSION) {
716
+ throw new DocScopeVersionMismatchError();
717
+ }
718
+ const result = docScopeSchemaV1.safeParse(parsed);
719
+ if (!result.success) {
720
+ throw new DocScopeCorruptError(
721
+ `${DOC_SCOPE_RELATIVE_PATH} is malformed: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
722
+ );
723
+ }
724
+ return result.data;
725
+ }
726
+ async function writeDocScope(paths, options) {
727
+ const root = options?.repoRoot ?? await getRepoRoot();
728
+ if (!Array.isArray(paths) || paths.length === 0) {
729
+ throw new DocScopeValidationError("at least one path is required");
730
+ }
731
+ const errors = [];
732
+ for (const entry of paths) {
733
+ const err = validateDocScopeEntry(entry, REPO_ROOT_REL);
734
+ if (err !== null) errors.push(err);
735
+ }
736
+ if (errors.length > 0) {
737
+ throw new DocScopeValidationError(
738
+ `${DOC_SCOPE_RELATIVE_PATH}: invalid path(s):
739
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
740
+ );
741
+ }
742
+ const normalised = normalizeDocScope(paths);
743
+ if (normalised.length === 0) {
744
+ throw new DocScopeValidationError(
745
+ `${DOC_SCOPE_RELATIVE_PATH}: every entry collapses to an empty scope after normalisation (e.g. '.', './', 'docs/..') \u2014 provide at least one concrete path`
746
+ );
747
+ }
748
+ const target = path2.join(root, DOC_SCOPE_RELATIVE_PATH);
749
+ await fs2.mkdir(path2.dirname(target), { recursive: true });
750
+ const payload = { version: DOC_SCOPE_VERSION, doc_scope: normalised };
751
+ const json = `${JSON.stringify(payload, null, 2)}
752
+ `;
753
+ await fs2.writeFile(target, json, "utf8");
754
+ }
755
+ async function docScopeExists(repoRoot) {
756
+ const root = repoRoot ?? await getRepoRoot();
757
+ const target = path2.join(root, DOC_SCOPE_RELATIVE_PATH);
758
+ try {
759
+ const st = await fs2.stat(target);
760
+ return st.isFile();
761
+ } catch {
762
+ return false;
763
+ }
764
+ }
765
+ async function deleteDocScope(repoRoot) {
766
+ const root = repoRoot ?? await getRepoRoot();
767
+ const target = path2.join(root, DOC_SCOPE_RELATIVE_PATH);
768
+ try {
769
+ await fs2.unlink(target);
770
+ } catch (err) {
771
+ if (!isNoEntError(err)) throw err;
772
+ }
773
+ }
774
+ async function expandDocScope(paths, repoRoot) {
775
+ const root = repoRoot ?? await getRepoRoot();
776
+ const normalisedRoot = path2.resolve(root);
777
+ const found = /* @__PURE__ */ new Set();
778
+ const missing = [];
779
+ for (const rawEntry of paths) {
780
+ if (typeof rawEntry !== "string") continue;
781
+ const normalised = normalizeDocScope([rawEntry]);
782
+ if (normalised.length === 0) {
783
+ if (rawEntry.trim().length > 0) missing.push(rawEntry);
784
+ continue;
785
+ }
786
+ const entry = normalised[0];
787
+ if (validateDocScopeEntry(entry, REPO_ROOT_REL) !== null) {
788
+ missing.push(rawEntry);
789
+ continue;
790
+ }
791
+ if (classifyEntry(entry) === "glob") {
792
+ const matches = await glob(entry, {
793
+ cwd: root,
794
+ absolute: true,
795
+ onlyFiles: true,
796
+ dot: false,
797
+ // Case-folding parity with the engine predicate (`nocase: true`).
798
+ caseSensitiveMatch: false,
799
+ // Migrating from fast-glob — disable tinyglobby's directory-pattern
800
+ // auto-expansion so a glob like `packages/*/README.md` keeps exact
801
+ // fast-glob semantics.
802
+ expandDirectories: false
803
+ });
804
+ const inRoot = matches.filter((m) => isInsideRoot(m, normalisedRoot));
805
+ if (inRoot.length === 0) {
806
+ missing.push(rawEntry);
807
+ } else {
808
+ for (const m of inRoot) found.add(m);
809
+ }
810
+ continue;
811
+ }
812
+ const absolute = path2.resolve(root, entry);
813
+ let stat;
814
+ try {
815
+ stat = await fs2.stat(absolute);
816
+ } catch (err) {
817
+ if (isNoEntError(err)) {
818
+ missing.push(rawEntry);
819
+ continue;
820
+ }
821
+ throw err;
822
+ }
823
+ if (stat.isDirectory()) {
824
+ const children = await glob("**/*.md", {
825
+ cwd: absolute,
826
+ absolute: true,
827
+ onlyFiles: true,
828
+ caseSensitiveMatch: false,
829
+ dot: false,
830
+ expandDirectories: false
831
+ });
832
+ for (const c of children) {
833
+ if (isInsideRoot(c, normalisedRoot)) found.add(c);
834
+ }
835
+ } else if (stat.isFile()) {
836
+ if (isInsideRoot(absolute, normalisedRoot)) found.add(absolute);
837
+ } else {
838
+ missing.push(rawEntry);
839
+ }
840
+ }
841
+ const files = Array.from(found).sort();
842
+ return { files, missingPaths: missing };
843
+ }
844
+ function isNoEntError(err) {
845
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
846
+ }
847
+ function isInsideRoot(absolute, normalisedRoot) {
848
+ const resolved = path2.resolve(absolute);
849
+ return resolved === normalisedRoot || resolved.startsWith(normalisedRoot + path2.sep);
850
+ }
851
+
852
+ // src/commands/local-prepare.ts
853
+ var execFileAsync = promisify(execFile);
854
+ var UNTRACKED_DIFF_MAX_BUFFER = 64 * 1024 * 1024;
855
+ var PROMPT_TOKEN_BUDGET = 15e4;
856
+ var DEFAULT_RELEVANCE_THRESHOLD = 5;
857
+ function formatMissingPathWarning(path5) {
858
+ return `\u26A0\uFE0F Skipped: \`${path5}\` (no longer exists)
859
+ `;
860
+ }
861
+ var PROMPT_TEMPLATE_CANDIDATES = [
862
+ "./prompt.md",
863
+ "../../../drift-engine/src/prompt.md"
864
+ ];
865
+ function resolvePromptTemplatePath(baseUrl) {
866
+ const tried = [];
867
+ for (const rel of PROMPT_TEMPLATE_CANDIDATES) {
868
+ const candidate = fileURLToPath2(new URL(rel, baseUrl));
869
+ if (existsSync3(candidate)) return candidate;
870
+ tried.push(candidate);
871
+ }
872
+ throw new Error(
873
+ "Could not locate the drift-engine prompt template. Tried:\n" + tried.map((p) => ` - ${p}`).join("\n") + "\nIf running the bundled CLI, ensure `dist/prompt.md` was produced by the build (tsup onSuccess copies drift-engine/src/prompt.md). Re-run `pnpm --filter @delfini/cli build`."
874
+ );
875
+ }
876
+ var cachedTemplate;
877
+ function loadTemplate() {
878
+ if (cachedTemplate === void 0) {
879
+ cachedTemplate = readFileSync3(resolvePromptTemplatePath(import.meta.url), "utf8");
880
+ }
881
+ return cachedTemplate;
882
+ }
883
+ async function runLocalPrepare(options = {}) {
884
+ const stderr = options.stderr ?? process.stderr;
885
+ const stdout = options.stdout ?? process.stdout;
886
+ const budget = options.promptTokenBudget ?? PROMPT_TOKEN_BUDGET;
887
+ const diffSource = options.diffSource ?? "local";
888
+ if (diffSource !== "local" && diffSource !== "committed" && diffSource !== "both") {
889
+ throw new Error(
890
+ `Invalid --diff-source "${String(diffSource)}". Valid values: local, committed, both.`
891
+ );
892
+ }
893
+ const repoRoot = options.repoRoot ?? await getRepoRoot();
894
+ const scopePaths = await resolveScopePaths(options.scope, repoRoot);
895
+ if (scopePaths === null) {
896
+ stderr.write(
897
+ "No doc-scope configured. Pass `--scope <paths>` or run the skill\nfirst-run setup to create `.claude/skills/delfini/doc-scope.json`.\n"
898
+ );
899
+ return 2;
900
+ }
901
+ const expansion = await expandDocScope(scopePaths, repoRoot);
902
+ for (const missing of expansion.missingPaths) {
903
+ stderr.write(formatMissingPathWarning(missing));
904
+ }
905
+ const git = simpleGit3({ baseDir: repoRoot });
906
+ const baseRef = await resolveBaseRef(git, options.base, stderr);
907
+ const rawDiff = await computeDiff(git, repoRoot, baseRef, diffSource);
908
+ let diff = rawDiff;
909
+ let filterResult = null;
910
+ if (options.enableDiffPreFilter === true) {
911
+ filterResult = filterDiff(rawDiff);
912
+ diff = filterResult.keptDiff;
913
+ }
914
+ const docs = await readDocs(expansion.files, repoRoot, stderr);
915
+ const prMetadata = await buildPRMetadata(git, repoRoot, baseRef);
916
+ const input = { diff, docs, prMetadata };
917
+ const useRankedFill = typeof options.relevanceThreshold === "number" && Number.isFinite(options.relevanceThreshold) && options.relevanceThreshold > 0;
918
+ const buildResult = buildPromptWithDrops(input, loadTemplate(), {
919
+ relevanceThreshold: options.relevanceThreshold,
920
+ // Pass the budget ONLY when retrieval is on; otherwise omit so the
921
+ // default code path stays observably unchanged (AC5).
922
+ promptTokenBudget: useRankedFill ? budget : void 0
923
+ });
924
+ const prompt = buildResult.prompt;
925
+ const droppedSections = buildResult.droppedSections;
926
+ const estimatedTokens = estimatePromptTokens(prompt);
927
+ if (estimatedTokens > budget) {
928
+ const payload = {
929
+ error: "prompt_too_large",
930
+ estimatedTokens,
931
+ suggestion: "Narrow your doc-scope (try '/delfini --scope <fewer-paths>'), split the PR, or shrink the diff \u2014 the prompt is over budget even before any doc sections fit."
932
+ };
933
+ stdout.write(`${JSON.stringify(payload)}
934
+ `);
935
+ return 4;
936
+ }
937
+ if (droppedSections.length > 0) {
938
+ stderr.write(`dropped ${droppedSections.length} section(s) \u2014 over prompt budget
939
+ `);
940
+ }
941
+ ensureTraceDir(repoRoot);
942
+ const schemaJson = zodToJsonSchema(analysisSchema);
943
+ const traceJson = { ...input };
944
+ if (filterResult !== null) {
945
+ traceJson._filterResult = {
946
+ droppedPaths: filterResult.droppedPaths,
947
+ droppedHunks: filterResult.droppedHunks
948
+ };
949
+ }
950
+ if (droppedSections.length > 0) {
951
+ traceJson._rankedFillResult = { droppedSections };
952
+ }
953
+ writeTraceFile(repoRoot, "analysis-input.json", `${JSON.stringify(traceJson, null, 2)}
954
+ `);
955
+ writeTraceFile(repoRoot, "analysis-prompt.md", prompt);
956
+ writeTraceFile(repoRoot, "schema.json", `${JSON.stringify(schemaJson, null, 2)}
957
+ `);
958
+ return 0;
959
+ }
960
+ async function resolveScopePaths(scopeOption, repoRoot) {
961
+ if (scopeOption !== void 0) {
962
+ const normalised = normaliseScopeOption(scopeOption);
963
+ if (normalised.length === 0) {
964
+ return null;
965
+ }
966
+ return normalised;
967
+ }
968
+ const persisted = await readDocScope(repoRoot);
969
+ if (persisted === null) {
970
+ return null;
971
+ }
972
+ return persisted.doc_scope;
973
+ }
974
+ function normaliseScopeOption(scope) {
975
+ const raw = Array.isArray(scope) ? scope : scope.split(",");
976
+ return raw.map((s) => s.trim()).filter((s) => s.length > 0);
977
+ }
978
+ async function computeDiff(git, repoRoot, baseRef, diffSource) {
979
+ switch (diffSource) {
980
+ case "committed":
981
+ return git.diff([baseRef, "HEAD"]);
982
+ case "local": {
983
+ const tracked = await git.diff(["HEAD"]);
984
+ const untracked = await computeUntrackedDiff(git, repoRoot);
985
+ return concatDiff(tracked, untracked);
986
+ }
987
+ case "both": {
988
+ const tracked = await git.diff([baseRef]);
989
+ const untracked = await computeUntrackedDiff(git, repoRoot);
990
+ return concatDiff(tracked, untracked);
991
+ }
992
+ }
993
+ }
994
+ async function computeUntrackedDiff(git, repoRoot) {
995
+ const untracked = await listUntrackedFiles(git);
996
+ if (untracked.length === 0) {
997
+ return "";
998
+ }
999
+ const parts = [];
1000
+ for (const rel of untracked) {
1001
+ const fileDiff = await diffUntrackedFile(repoRoot, rel);
1002
+ if (fileDiff.length > 0) {
1003
+ parts.push(fileDiff);
1004
+ }
1005
+ }
1006
+ return parts.join("");
1007
+ }
1008
+ async function diffUntrackedFile(repoRoot, relPath) {
1009
+ const args = ["-C", repoRoot, "diff", "--no-index", "--no-color", "--", "/dev/null", relPath];
1010
+ try {
1011
+ const { stdout } = await execFileAsync("git", args, { maxBuffer: UNTRACKED_DIFF_MAX_BUFFER });
1012
+ return stdout;
1013
+ } catch (err) {
1014
+ const e = err;
1015
+ if (e.code === 1 && typeof e.stdout === "string") {
1016
+ return e.stdout;
1017
+ }
1018
+ throw err;
1019
+ }
1020
+ }
1021
+ function concatDiff(tracked, untracked) {
1022
+ if (tracked.length === 0) return untracked;
1023
+ if (untracked.length === 0) return tracked;
1024
+ return tracked.endsWith("\n") ? `${tracked}${untracked}` : `${tracked}
1025
+ ${untracked}`;
1026
+ }
1027
+ async function readDocs(absolutePaths, repoRoot, stderr) {
1028
+ const docs = [];
1029
+ for (const abs of absolutePaths) {
1030
+ const relative = path3.relative(repoRoot, abs).split(path3.sep).join("/");
1031
+ let content;
1032
+ try {
1033
+ content = await fs3.readFile(abs, "utf8");
1034
+ } catch (err) {
1035
+ if (isNoEntError2(err)) {
1036
+ stderr.write(formatMissingPathWarning(relative));
1037
+ continue;
1038
+ }
1039
+ throw err;
1040
+ }
1041
+ docs.push({
1042
+ path: relative,
1043
+ content,
1044
+ // V1 — no YAML front-matter parsing. See story Dev Notes §
1045
+ // "frontMatterLineCount deferral".
1046
+ frontMatterLineCount: 0
1047
+ });
1048
+ }
1049
+ return docs;
1050
+ }
1051
+ function isNoEntError2(err) {
1052
+ return typeof err === "object" && err !== null && err.code === "ENOENT";
1053
+ }
1054
+ async function buildPRMetadata(git, repoRoot, baseRef) {
1055
+ const headSha = await safeShortSha(git, "HEAD");
1056
+ const baseSha = await safeShortSha(git, baseRef);
1057
+ return {
1058
+ owner: "local",
1059
+ repo: path3.basename(repoRoot),
1060
+ prNumber: 0,
1061
+ headSha,
1062
+ baseSha,
1063
+ title: "Local /delfini run"
1064
+ };
1065
+ }
1066
+ async function safeShortSha(git, ref) {
1067
+ try {
1068
+ const raw = await git.revparse(["--short", ref]);
1069
+ const trimmed = raw.trim();
1070
+ return trimmed.length > 0 ? trimmed : "unknown";
1071
+ } catch {
1072
+ return "unknown";
1073
+ }
1074
+ }
1075
+ function zodToJsonSchema(schema) {
1076
+ return walk(schema);
1077
+ }
1078
+ function walk(schema) {
1079
+ const def = schema._def;
1080
+ const typeName = def.typeName;
1081
+ switch (typeName) {
1082
+ case "ZodObject": {
1083
+ const shape = schema.shape;
1084
+ const properties = {};
1085
+ const required = [];
1086
+ for (const [key, child] of Object.entries(shape)) {
1087
+ const childSchema = child;
1088
+ properties[key] = walk(childSchema);
1089
+ const childTypeName = childSchema._def.typeName;
1090
+ if (childTypeName !== "ZodOptional" && childTypeName !== "ZodDefault") {
1091
+ required.push(key);
1092
+ }
1093
+ }
1094
+ const result = { type: "object", properties, additionalProperties: false };
1095
+ if (required.length > 0) {
1096
+ result.required = required;
1097
+ }
1098
+ return result;
1099
+ }
1100
+ case "ZodArray": {
1101
+ const inner = def.type;
1102
+ return { type: "array", items: walk(inner) };
1103
+ }
1104
+ case "ZodString": {
1105
+ const result = { type: "string" };
1106
+ const checks = def.checks ?? [];
1107
+ for (const check of checks) {
1108
+ if (check.kind === "min" && typeof check.value === "number") {
1109
+ result.minLength = check.value;
1110
+ }
1111
+ }
1112
+ return result;
1113
+ }
1114
+ case "ZodNumber": {
1115
+ const result = { type: "number" };
1116
+ const checks = def.checks ?? [];
1117
+ for (const check of checks) {
1118
+ if (check.kind === "min" && typeof check.value === "number") {
1119
+ result.minimum = check.value;
1120
+ }
1121
+ if (check.kind === "max" && typeof check.value === "number") {
1122
+ result.maximum = check.value;
1123
+ }
1124
+ if (check.kind === "int") {
1125
+ result.type = "integer";
1126
+ }
1127
+ }
1128
+ return result;
1129
+ }
1130
+ case "ZodEnum": {
1131
+ const values = def.values;
1132
+ return { type: "string", enum: values };
1133
+ }
1134
+ case "ZodLiteral": {
1135
+ const value = def.value;
1136
+ const literal = { const: value };
1137
+ if (typeof value === "string") literal.type = "string";
1138
+ else if (typeof value === "number") literal.type = "number";
1139
+ else if (typeof value === "boolean") literal.type = "boolean";
1140
+ return literal;
1141
+ }
1142
+ case "ZodNullable": {
1143
+ const inner = def.innerType;
1144
+ const innerSchema = walk(inner);
1145
+ if (typeof innerSchema.type === "string") {
1146
+ return { ...innerSchema, type: [innerSchema.type, "null"] };
1147
+ }
1148
+ return { ...innerSchema, type: Array.isArray(innerSchema.type) ? [...innerSchema.type, "null"] : ["null"] };
1149
+ }
1150
+ case "ZodOptional":
1151
+ case "ZodDefault": {
1152
+ const inner = def.innerType;
1153
+ return walk(inner);
1154
+ }
1155
+ default:
1156
+ throw new Error(
1157
+ `zodToJsonSchema: unsupported Zod type "${typeName ?? "unknown"}" \u2014 extend the walker.`
1158
+ );
1159
+ }
1160
+ }
1161
+
1162
+ // src/cli.ts
1163
+ var pkg = readPackageJson();
1164
+ function readPackageJson() {
1165
+ const here = path4.dirname(fileURLToPath3(import.meta.url));
1166
+ const pkgPath = path4.join(here, "..", "package.json");
1167
+ const raw = readFileSync4(pkgPath, "utf8");
1168
+ return JSON.parse(raw);
1169
+ }
1170
+ async function main(argv) {
1171
+ const program = new Command();
1172
+ program.name("delfini").description("Delfini Skill CLI \u2014 deterministic, never calls an LLM.").version(pkg.version, "-V, --version", "print the @delfini/cli version").option("--reset-scope", "delete the persisted doc-scope.json").exitOverride();
1173
+ program.action(async (opts) => {
1174
+ if (opts.resetScope) {
1175
+ await handleResetScope();
1176
+ }
1177
+ });
1178
+ program.command("install <path>").description(
1179
+ "Scaffold .claude/skills/delfini/SKILL.md + CLAUDE.md auto-invoke + .gitignore append"
1180
+ ).option("--tool <agent>", "Coding agent target (only 'CLAUDE' supported in V1)", "CLAUDE").option("--auto-invoke", "append the CLAUDE.md auto-invoke block without prompting").option("--no-auto-invoke", "strip the CLAUDE.md auto-invoke block without prompting").action(async (targetPath, opts) => {
1181
+ const confirmAutoInvoke = opts.autoInvoke === void 0 ? void 0 : () => Promise.resolve(opts.autoInvoke);
1182
+ await runInstall(targetPath, { tool: opts.tool, confirmAutoInvoke });
1183
+ });
1184
+ program.command("local-prepare").description(
1185
+ "Compute diff + doc-scope + prompt + token-budget gate; write .delfini-trace/"
1186
+ ).option("--scope <paths>", "Comma-separated doc-scope paths (overrides doc-scope.json)").option("--base <ref>", "Diff base ref (default: git merge-base HEAD origin/main)").option(
1187
+ "--diff-source <source>",
1188
+ "Which diff to analyse: 'local' (default), 'committed', or 'both'",
1189
+ "local"
1190
+ ).option(
1191
+ "--relevance-threshold <n>",
1192
+ `Render only doc SECTIONS scoring at/above N against the diff (Tier 1 +20 / Tier 2 +10 per file / Tier 3 +3 per identifier capped at 30 / Tier 4 +5 per heading), most-relevant-first up to the prompt budget. Default: ${DEFAULT_RELEVANCE_THRESHOLD} (token-efficient retrieval on). Pass 0 to disable and embed every in-scope doc whole. When retained sections still exceed the prompt budget, ranked-fill drops the lowest-scoring sections first and reports what was dropped.`,
1193
+ (value) => {
1194
+ if (!/^\d+$/.test(value)) {
1195
+ throw new Error("--relevance-threshold must be a non-negative integer");
1196
+ }
1197
+ const parsed = Number.parseInt(value, 10);
1198
+ if (!Number.isFinite(parsed)) {
1199
+ throw new Error("--relevance-threshold must be a non-negative integer");
1200
+ }
1201
+ return parsed;
1202
+ },
1203
+ // Default ON at the CLI call-site (NFR49). `runLocalPrepare` stays a pure
1204
+ // pass-through — this default is what makes a real `delfini local-prepare`
1205
+ // run retrieve. Commander applies the default verbatim (it does not run
1206
+ // the parser above on it), so a numeric literal is correct here.
1207
+ DEFAULT_RELEVANCE_THRESHOLD
1208
+ ).option(
1209
+ "--enable-diff-prefilter",
1210
+ "Drop lockfile/generated/vendored/fixture paths + pure-whitespace/import-only hunks from the diff before prompt assembly (Story P3.7.2 / FR151). Default: off \u2014 assembled prompt is byte-identical to the no-flag baseline."
1211
+ ).action(
1212
+ async (opts) => {
1213
+ const exitCode = await runLocalPrepare({
1214
+ scope: opts.scope,
1215
+ base: opts.base,
1216
+ diffSource: opts.diffSource,
1217
+ relevanceThreshold: opts.relevanceThreshold,
1218
+ enableDiffPreFilter: opts.enableDiffPrefilter
1219
+ });
1220
+ process.exitCode = exitCode;
1221
+ }
1222
+ );
1223
+ program.command("diff-status").description("Report branch + local/committed change state as JSON (read-only)").option("--base <ref>", "Diff base ref (default: git merge-base HEAD origin/main)").action(async (opts) => {
1224
+ const exitCode = await runDiffStatus({ base: opts.base });
1225
+ process.exitCode = exitCode;
1226
+ });
1227
+ program.command("local-finalize <findingsPath>").description(
1228
+ "Validate findings.json, reconcile line numbers, render .delfini-trace/report.md"
1229
+ ).action(async (findingsPath) => {
1230
+ const exitCode = await runLocalFinalize({ findingsPath });
1231
+ process.exitCode = exitCode;
1232
+ });
1233
+ try {
1234
+ await program.parseAsync(argv);
1235
+ } catch (err) {
1236
+ if (err instanceof CommanderError && err.exitCode === 0) {
1237
+ return;
1238
+ }
1239
+ throw err;
1240
+ }
1241
+ }
1242
+ async function handleResetScope() {
1243
+ try {
1244
+ await deleteDocScope();
1245
+ } catch (err) {
1246
+ if (err instanceof RepoRootNotFoundError) {
1247
+ return;
1248
+ }
1249
+ throw err;
1250
+ }
1251
+ }
1252
+
1253
+ export {
1254
+ RepoRootNotFoundError,
1255
+ getRepoRoot,
1256
+ runDiffStatus,
1257
+ ensureTraceDir,
1258
+ appendToGitignore,
1259
+ writeTraceFile,
1260
+ writeRetryAttemptFile,
1261
+ InstallToolNotSupportedError,
1262
+ runInstall,
1263
+ runLocalFinalize,
1264
+ DOC_SCOPE_RELATIVE_PATH,
1265
+ DOC_SCOPE_VERSION,
1266
+ DocScopeVersionMismatchError,
1267
+ DocScopeCorruptError,
1268
+ DocScopeValidationError,
1269
+ readDocScope,
1270
+ writeDocScope,
1271
+ docScopeExists,
1272
+ deleteDocScope,
1273
+ expandDocScope,
1274
+ PROMPT_TOKEN_BUDGET,
1275
+ runLocalPrepare,
1276
+ main
1277
+ };