fathom-cli 0.3.5 → 0.3.6

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/dist/index.js CHANGED
@@ -102,7 +102,7 @@ var require_extend = __commonJS({
102
102
 
103
103
  // src/index.ts
104
104
  import "dotenv/config";
105
- import { Command as Command22 } from "commander";
105
+ import { Command as Command24 } from "commander";
106
106
 
107
107
  // ../../node_modules/.pnpm/bail@2.0.2/node_modules/bail/index.js
108
108
  function bail(error) {
@@ -1104,7 +1104,7 @@ var Processor = class _Processor extends CallableInstance {
1104
1104
  assertParser("process", this.parser || this.Parser);
1105
1105
  assertCompiler("process", this.compiler || this.Compiler);
1106
1106
  return done ? executor(void 0, done) : new Promise(executor);
1107
- function executor(resolve25, reject) {
1107
+ function executor(resolve26, reject) {
1108
1108
  const realFile = vfile(file);
1109
1109
  const parseTree = (
1110
1110
  /** @type {HeadTree extends undefined ? Node : HeadTree} */
@@ -1135,8 +1135,8 @@ var Processor = class _Processor extends CallableInstance {
1135
1135
  function realDone(error, file2) {
1136
1136
  if (error || !file2) {
1137
1137
  reject(error);
1138
- } else if (resolve25) {
1139
- resolve25(file2);
1138
+ } else if (resolve26) {
1139
+ resolve26(file2);
1140
1140
  } else {
1141
1141
  ok(done, "`done` is defined if `resolve` is not");
1142
1142
  done(void 0, file2);
@@ -1238,7 +1238,7 @@ var Processor = class _Processor extends CallableInstance {
1238
1238
  file = void 0;
1239
1239
  }
1240
1240
  return done ? executor(void 0, done) : new Promise(executor);
1241
- function executor(resolve25, reject) {
1241
+ function executor(resolve26, reject) {
1242
1242
  ok(
1243
1243
  typeof file !== "function",
1244
1244
  "`file` can\u2019t be a `done` anymore, we checked"
@@ -1252,8 +1252,8 @@ var Processor = class _Processor extends CallableInstance {
1252
1252
  );
1253
1253
  if (error) {
1254
1254
  reject(error);
1255
- } else if (resolve25) {
1256
- resolve25(resultingTree);
1255
+ } else if (resolve26) {
1256
+ resolve26(resultingTree);
1257
1257
  } else {
1258
1258
  ok(done, "`done` is defined if `resolve` is not");
1259
1259
  done(void 0, resultingTree, file2);
@@ -4081,10 +4081,10 @@ function resolveAll(constructs2, events, context) {
4081
4081
  const called = [];
4082
4082
  let index2 = -1;
4083
4083
  while (++index2 < constructs2.length) {
4084
- const resolve25 = constructs2[index2].resolveAll;
4085
- if (resolve25 && !called.includes(resolve25)) {
4086
- events = resolve25(events, context);
4087
- called.push(resolve25);
4084
+ const resolve26 = constructs2[index2].resolveAll;
4085
+ if (resolve26 && !called.includes(resolve26)) {
4086
+ events = resolve26(events, context);
4087
+ called.push(resolve26);
4088
4088
  }
4089
4089
  }
4090
4090
  return events;
@@ -8533,7 +8533,12 @@ var FeatureEstimate = external_exports.object({
8533
8533
  errorRecovery: external_exports.number(),
8534
8534
  planning: external_exports.number(),
8535
8535
  review: external_exports.number()
8536
- })
8536
+ }),
8537
+ confidence: external_exports.object({
8538
+ low: external_exports.object({ tokens: external_exports.number(), cost: external_exports.number() }),
8539
+ expected: external_exports.object({ tokens: external_exports.number(), cost: external_exports.number() }),
8540
+ high: external_exports.object({ tokens: external_exports.number(), cost: external_exports.number() })
8541
+ }).optional()
8537
8542
  });
8538
8543
  var SensitivityScenario = external_exports.object({
8539
8544
  name: external_exports.string(),
@@ -8751,7 +8756,7 @@ var AgentsFile = external_exports.object({
8751
8756
  });
8752
8757
  function loadYaml(filePath, schema) {
8753
8758
  const content3 = readFileSync(filePath, "utf-8");
8754
- const raw = jsYaml.load(content3);
8759
+ const raw = jsYaml.load(content3, { schema: jsYaml.JSON_SCHEMA });
8755
8760
  return schema.parse(raw);
8756
8761
  }
8757
8762
  function findDataDir() {
@@ -8764,18 +8769,32 @@ function findDataDir() {
8764
8769
  return resolve(base, "../../../data");
8765
8770
  }
8766
8771
  }
8767
- function loadData(dataDir) {
8772
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
8773
+ var cacheMap = /* @__PURE__ */ new Map();
8774
+ function loadData(dataDir, ttlMs) {
8768
8775
  const dir = dataDir ?? findDataDir();
8776
+ const resolvedDir = resolve(dir);
8777
+ const ttl = ttlMs ?? DEFAULT_TTL_MS;
8778
+ if (ttl > 0) {
8779
+ const cached = cacheMap.get(resolvedDir);
8780
+ if (cached && Date.now() - cached.timestamp < ttl) {
8781
+ return cached.data;
8782
+ }
8783
+ }
8769
8784
  const modelsFile = loadYaml(resolve(dir, "models.yaml"), ModelsFile);
8770
8785
  const profilesFile = loadYaml(resolve(dir, "task-profiles.yaml"), TaskProfilesFile);
8771
8786
  const agentsFile = loadYaml(resolve(dir, "agents.yaml"), AgentsFile);
8772
- return {
8787
+ const data = {
8773
8788
  models: modelsFile.models,
8774
8789
  profiles: profilesFile.profiles,
8775
8790
  complexityMultipliers: profilesFile.complexity_multipliers,
8776
8791
  overhead: profilesFile.overhead_multipliers,
8777
8792
  recommendations: agentsFile.recommendations
8778
8793
  };
8794
+ if (ttl > 0) {
8795
+ cacheMap.set(resolvedDir, { data, timestamp: Date.now() });
8796
+ }
8797
+ return data;
8779
8798
  }
8780
8799
  function getProfile(profiles, taskType) {
8781
8800
  return profiles[taskType];
@@ -8861,6 +8880,20 @@ function estimateFeature(feature, data) {
8861
8880
  const overheadOutput = Math.round(baseOutput * multiplier + breakdown.toolCalls * 0.75);
8862
8881
  const overheadTotal = overheadInput + overheadOutput;
8863
8882
  const cost = calculateCost(overheadInput, overheadOutput, pricing);
8883
+ const confidence = {
8884
+ low: {
8885
+ tokens: Math.round(overheadTotal * 0.7),
8886
+ cost: Math.round(cost * 0.7 * 100) / 100
8887
+ },
8888
+ expected: {
8889
+ tokens: overheadTotal,
8890
+ cost
8891
+ },
8892
+ high: {
8893
+ tokens: Math.round(overheadTotal * 1.5),
8894
+ cost: Math.round(cost * 1.5 * 100) / 100
8895
+ }
8896
+ };
8864
8897
  return {
8865
8898
  featureId: feature.id,
8866
8899
  featureName: feature.name,
@@ -8871,7 +8904,8 @@ function estimateFeature(feature, data) {
8871
8904
  baseTokens: { input: baseInput, output: baseOutput, total: baseTotal },
8872
8905
  withOverhead: { input: overheadInput, output: overheadOutput, total: overheadTotal },
8873
8906
  estimatedCost: cost,
8874
- overheadBreakdown: breakdown
8907
+ overheadBreakdown: breakdown,
8908
+ confidence
8875
8909
  };
8876
8910
  }
8877
8911
  function estimateAll(features, data) {
@@ -10005,7 +10039,40 @@ async function syncVelocity(data) {
10005
10039
  }
10006
10040
  }
10007
10041
 
10042
+ // src/ux/spinner.ts
10043
+ var FRAMES = ["\u28CB", "\u28D9", "\u28F9", "\u28F8", "\u28FC", "\u28F4", "\u28E6", "\u28E7", "\u28C7", "\u28CF"];
10044
+ var INTERVAL_MS = 80;
10045
+ async function withSpinner(text3, fn) {
10046
+ const start = Date.now();
10047
+ const isTTY = process.stderr.isTTY;
10048
+ if (!isTTY) {
10049
+ return fn();
10050
+ }
10051
+ let frameIndex = 0;
10052
+ const timer = setInterval(() => {
10053
+ const frame = FRAMES[frameIndex % FRAMES.length];
10054
+ process.stderr.write(`\r\x1B[K${frame} ${text3}`);
10055
+ frameIndex++;
10056
+ }, INTERVAL_MS);
10057
+ try {
10058
+ const result = await fn();
10059
+ clearInterval(timer);
10060
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
10061
+ process.stderr.write(`\r\x1B[K\x1B[32m\u2713\x1B[0m ${text3} (${elapsed}s)
10062
+ `);
10063
+ return result;
10064
+ } catch (error) {
10065
+ clearInterval(timer);
10066
+ process.stderr.write(`\r\x1B[K\x1B[31m\u2717\x1B[0m ${text3}
10067
+ `);
10068
+ throw error;
10069
+ }
10070
+ }
10071
+
10008
10072
  // src/commands/intake.ts
10073
+ function isWithinBoundary(resolvedPath, boundary) {
10074
+ return resolvedPath.startsWith(boundary);
10075
+ }
10009
10076
  function findLocalMarkdownFiles() {
10010
10077
  try {
10011
10078
  const cwd = process.cwd();
@@ -10027,16 +10094,24 @@ async function runIntakeFlow(projectName) {
10027
10094
  process.exit(1);
10028
10095
  }
10029
10096
  const provider = hasAnthropicKey ? "anthropic" : "openai";
10030
- console.log(chalk3.dim(`
10031
- Extracting work items via ${provider === "anthropic" ? "Claude" : "OpenAI"}...`));
10032
10097
  let items;
10033
10098
  try {
10034
- items = await extractWorkItems(rawInput, {
10035
- provider,
10036
- ...provider === "openai" ? { openaiApiKey: process.env.OPENAI_API_KEY } : {}
10037
- });
10099
+ items = await withSpinner(
10100
+ `Extracting work items via ${provider === "anthropic" ? "Claude" : "OpenAI"}...`,
10101
+ async () => extractWorkItems(rawInput, {
10102
+ provider,
10103
+ ...provider === "openai" ? { openaiApiKey: process.env.OPENAI_API_KEY } : {}
10104
+ })
10105
+ );
10038
10106
  } catch (err) {
10039
- console.error(chalk3.red(" Extraction failed:"), err.message);
10107
+ let errMsg = err.message;
10108
+ for (const envKey of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]) {
10109
+ const val = process.env[envKey];
10110
+ if (val && errMsg.includes(val)) {
10111
+ errMsg = errMsg.replaceAll(val, val.length <= 6 ? "***" : val.slice(0, 3) + "***" + val.slice(-3));
10112
+ }
10113
+ }
10114
+ console.error(chalk3.red(" Extraction failed:"), errMsg);
10040
10115
  process.exit(1);
10041
10116
  }
10042
10117
  if (items.length === 0) {
@@ -10094,6 +10169,10 @@ async function resolveInput(cwd) {
10094
10169
  } else if (chosen === "__other__") {
10095
10170
  const filePath = await input({ message: "Path to input file:", default: cwd + "/" });
10096
10171
  const resolved = resolve5(filePath.trim());
10172
+ if (!isWithinBoundary(resolved, cwd)) {
10173
+ console.error(chalk3.red("Path traversal rejected: file must be within project directory"));
10174
+ process.exit(1);
10175
+ }
10097
10176
  if (!existsSync4(resolved)) {
10098
10177
  console.error(chalk3.red(`File not found: ${resolved}`));
10099
10178
  process.exit(1);
@@ -10113,6 +10192,10 @@ async function resolveInput(cwd) {
10113
10192
  if (inputType === "file") {
10114
10193
  const filePath = await input({ message: "Path to input file:", default: cwd + "/" });
10115
10194
  const resolved = resolve5(filePath.trim());
10195
+ if (!isWithinBoundary(resolved, cwd)) {
10196
+ console.error(chalk3.red("Path traversal rejected: file must be within project directory"));
10197
+ process.exit(1);
10198
+ }
10116
10199
  if (!existsSync4(resolved)) {
10117
10200
  console.error(chalk3.red(`File not found: ${resolved}`));
10118
10201
  process.exit(1);
@@ -10175,6 +10258,10 @@ var intakeCommand = new Command2("intake").description("Extract work items from
10175
10258
  let source;
10176
10259
  if (options.file) {
10177
10260
  const filePath = resolve5(options.file);
10261
+ if (!isWithinBoundary(filePath, cwd)) {
10262
+ console.error(chalk3.red("Path traversal rejected: file must be within project directory"));
10263
+ process.exit(1);
10264
+ }
10178
10265
  if (!existsSync4(filePath)) {
10179
10266
  console.error(chalk3.red(`File not found: ${filePath}`));
10180
10267
  process.exit(1);
@@ -10210,6 +10297,10 @@ var intakeCommand = new Command2("intake").description("Extract work items from
10210
10297
  default: cwd + "/"
10211
10298
  });
10212
10299
  const resolved = resolve5(filePath.trim());
10300
+ if (!isWithinBoundary(resolved, cwd)) {
10301
+ console.error(chalk3.red("Path traversal rejected: file must be within project directory"));
10302
+ process.exit(1);
10303
+ }
10213
10304
  if (!existsSync4(resolved)) {
10214
10305
  console.error(chalk3.red(`File not found: ${resolved}`));
10215
10306
  process.exit(1);
@@ -10234,6 +10325,10 @@ var intakeCommand = new Command2("intake").description("Extract work items from
10234
10325
  default: cwd + "/"
10235
10326
  });
10236
10327
  const resolved = resolve5(filePath.trim());
10328
+ if (!isWithinBoundary(resolved, cwd)) {
10329
+ console.error(chalk3.red("Path traversal rejected: file must be within project directory"));
10330
+ process.exit(1);
10331
+ }
10237
10332
  if (!existsSync4(resolved)) {
10238
10333
  console.error(chalk3.red(`File not found: ${resolved}`));
10239
10334
  process.exit(1);
@@ -10263,16 +10358,24 @@ var intakeCommand = new Command2("intake").description("Extract work items from
10263
10358
  }
10264
10359
  provider = hasAnthropicKey ? "anthropic" : "openai";
10265
10360
  }
10266
- console.log(chalk3.dim(`
10267
- Extracting work items via ${provider === "anthropic" ? "Claude" : "OpenAI"}...`));
10268
10361
  let items;
10269
10362
  try {
10270
- items = await extractWorkItems(rawInput, {
10271
- provider,
10272
- ...provider === "openai" ? { openaiApiKey: process.env.OPENAI_API_KEY } : {}
10273
- });
10363
+ items = await withSpinner(
10364
+ `Extracting work items via ${provider === "anthropic" ? "Claude" : "OpenAI"}...`,
10365
+ async () => extractWorkItems(rawInput, {
10366
+ provider,
10367
+ ...provider === "openai" ? { openaiApiKey: process.env.OPENAI_API_KEY } : {}
10368
+ })
10369
+ );
10274
10370
  } catch (err) {
10275
- console.error(chalk3.red("Extraction failed:"), err.message);
10371
+ let errMsg = err.message;
10372
+ for (const envKey of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]) {
10373
+ const val = process.env[envKey];
10374
+ if (val && errMsg.includes(val)) {
10375
+ errMsg = errMsg.replaceAll(val, val.length <= 6 ? "***" : val.slice(0, 3) + "***" + val.slice(-3));
10376
+ }
10377
+ }
10378
+ console.error(chalk3.red("Extraction failed:"), errMsg);
10276
10379
  process.exit(1);
10277
10380
  }
10278
10381
  if (items.length === 0) {
@@ -10754,9 +10857,9 @@ async function runSettingsFlow(projectName) {
10754
10857
  switch (setting) {
10755
10858
  case "intent": {
10756
10859
  const intentData = await captureIntentFlow(projectName);
10757
- const { saveIntent } = await import("./dist-XKZLNUDU.js");
10860
+ const { saveIntent } = await import("./dist-VMLJKWUO.js");
10758
10861
  await saveIntent(process.cwd(), intentData);
10759
- const { writeProjections } = await import("./dist-KXBSLOHP.js");
10862
+ const { writeProjections } = await import("./dist-HPXMBCZX.js");
10760
10863
  const files = await writeProjections(process.cwd(), intentData);
10761
10864
  console.log(chalk4.green(`
10762
10865
  \u2713 Intent saved to .fathom/intent.yaml`));
@@ -10765,7 +10868,7 @@ async function runSettingsFlow(projectName) {
10765
10868
  break;
10766
10869
  }
10767
10870
  case "budget": {
10768
- const { loadIntent, saveIntent } = await import("./dist-XKZLNUDU.js");
10871
+ const { loadIntent, saveIntent } = await import("./dist-VMLJKWUO.js");
10769
10872
  let existing;
10770
10873
  try {
10771
10874
  existing = await loadIntent(process.cwd());
@@ -10823,11 +10926,11 @@ var goCommand = new Command3("go").description("Run the full workflow: intake \u
10823
10926
  });
10824
10927
  if (captureNow) {
10825
10928
  const intentData = await captureIntentFlow(projectName);
10826
- const { saveIntent } = await import("./dist-XKZLNUDU.js");
10929
+ const { saveIntent } = await import("./dist-VMLJKWUO.js");
10827
10930
  await saveIntent(process.cwd(), intentData);
10828
10931
  projectName = intentData.project;
10829
10932
  updateProjectConfig({ project: projectName });
10830
- const { writeProjections } = await import("./dist-KXBSLOHP.js");
10933
+ const { writeProjections } = await import("./dist-HPXMBCZX.js");
10831
10934
  const files = await writeProjections(process.cwd(), intentData);
10832
10935
  console.log(chalk4.green(`
10833
10936
  \u2713 Intent saved to .fathom/intent.yaml`));
@@ -10841,11 +10944,11 @@ var goCommand = new Command3("go").description("Run the full workflow: intake \u
10841
10944
  console.log();
10842
10945
  const intentData = await captureIntentFlow(basename3(process.cwd()));
10843
10946
  projectName = intentData.project;
10844
- const { saveIntent } = await import("./dist-XKZLNUDU.js");
10947
+ const { saveIntent } = await import("./dist-VMLJKWUO.js");
10845
10948
  await saveIntent(process.cwd(), intentData);
10846
10949
  scaffoldProject(projectName, { quiet: true });
10847
10950
  updateProjectConfig({ project: projectName });
10848
- const { writeProjections } = await import("./dist-KXBSLOHP.js");
10951
+ const { writeProjections } = await import("./dist-HPXMBCZX.js");
10849
10952
  const files = await writeProjections(process.cwd(), intentData);
10850
10953
  console.log(chalk4.green(`
10851
10954
  \u2713 Project "${projectName}" initialized`));
@@ -11075,16 +11178,23 @@ async function executeBuildMode(mode, promptPath, state) {
11075
11178
  console.log(chalk4.yellow(" No features in queue for worktree."));
11076
11179
  return false;
11077
11180
  }
11181
+ if (!/^[a-zA-Z0-9_-]+$/.test(featureId)) {
11182
+ console.log(chalk4.red(` Invalid feature ID: "${featureId}" \u2014 only letters, digits, hyphens, underscores allowed`));
11183
+ return false;
11184
+ }
11078
11185
  const worktreePath = resolve7(process.cwd(), ".worktrees", featureId);
11079
11186
  const branchName = `feat/${featureId}`;
11080
11187
  console.log(chalk4.dim(`
11081
11188
  Creating worktree: ${branchName}`));
11082
11189
  try {
11083
- const { execSync: execSync2 } = await import("child_process");
11084
- execSync2(`git worktree add "${worktreePath}" -b "${branchName}"`, {
11190
+ const { spawnSync } = await import("child_process");
11191
+ const result = spawnSync("git", ["worktree", "add", worktreePath, "-b", branchName], {
11085
11192
  stdio: "pipe",
11086
11193
  cwd: process.cwd()
11087
11194
  });
11195
+ if (result.status !== 0) {
11196
+ throw new Error(result.stderr?.toString() || "git worktree failed");
11197
+ }
11088
11198
  console.log(chalk4.green(` \u2713 Worktree created at ${worktreePath}`));
11089
11199
  } catch {
11090
11200
  console.log(chalk4.yellow(` \u26A0 Worktree creation failed \u2014 launching in current directory`));
@@ -11113,7 +11223,7 @@ async function executeBuildMode(mode, promptPath, state) {
11113
11223
  return false;
11114
11224
  }
11115
11225
  function launchClaude(promptPath, cwd, firstFeature) {
11116
- return new Promise((resolve25) => {
11226
+ return new Promise((resolve26) => {
11117
11227
  const promptContent = readFileSync7(promptPath, "utf-8");
11118
11228
  const initialPrompt = firstFeature ? `Read the build context in your system prompt. Start working on feature \`${firstFeature}\` \u2014 read the spec reference, plan the implementation, and begin.` : "Read the build context in your system prompt and start working on the first feature in the priority queue.";
11119
11229
  const child = spawn("claude", ["--append-system-prompt", promptContent, initialPrompt], {
@@ -11130,11 +11240,11 @@ function launchClaude(promptPath, cwd, firstFeature) {
11130
11240
  } else {
11131
11241
  console.error(chalk4.red(` Failed to launch Claude Code: ${err.message}`));
11132
11242
  }
11133
- resolve25();
11243
+ resolve26();
11134
11244
  });
11135
11245
  child.on("close", () => {
11136
11246
  process.removeListener("SIGINT", sigintHandler);
11137
- resolve25();
11247
+ resolve26();
11138
11248
  });
11139
11249
  });
11140
11250
  }
@@ -11312,21 +11422,24 @@ var pricingCommand = new Command5("pricing").description("Show current model pri
11312
11422
  if (options.sync) {
11313
11423
  const client = getConvexClient();
11314
11424
  if (client) {
11315
- let synced = 0;
11316
- for (const m of data.models) {
11317
- try {
11318
- await client.mutation(api.modelPricing.upsert, {
11319
- model: m.name,
11320
- provider: "anthropic",
11321
- inputPerMTok: m.input_per_mtok,
11322
- outputPerMTok: m.output_per_mtok,
11323
- cacheReadPerMTok: m.cache_read_per_mtok,
11324
- batchDiscount: m.batch_discount
11325
- });
11326
- synced++;
11327
- } catch {
11425
+ const synced = await withSpinner("Fetching latest pricing data...", async () => {
11426
+ let count = 0;
11427
+ for (const m of data.models) {
11428
+ try {
11429
+ await client.mutation(api.modelPricing.upsert, {
11430
+ model: m.name,
11431
+ provider: "anthropic",
11432
+ inputPerMTok: m.input_per_mtok,
11433
+ outputPerMTok: m.output_per_mtok,
11434
+ cacheReadPerMTok: m.cache_read_per_mtok,
11435
+ batchDiscount: m.batch_discount
11436
+ });
11437
+ count++;
11438
+ } catch {
11439
+ }
11328
11440
  }
11329
- }
11441
+ return count;
11442
+ });
11330
11443
  logConvexStatus(synced > 0);
11331
11444
  if (synced > 0) {
11332
11445
  console.log(chalk6.dim(` ${synced} models synced to Convex.`));
@@ -11354,7 +11467,13 @@ var statusCommand = new Command6("status").description("Show project status from
11354
11467
  );
11355
11468
  return;
11356
11469
  }
11357
- const registry = JSON.parse(readFileSync9(registryPath, "utf-8"));
11470
+ let registry;
11471
+ try {
11472
+ registry = JSON.parse(readFileSync9(registryPath, "utf-8"));
11473
+ } catch {
11474
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
11475
+ return process.exit(1);
11476
+ }
11358
11477
  const projectName = options.project ?? registry.project ?? getProjectName();
11359
11478
  const summaryPath = resolve9(
11360
11479
  process.cwd(),
@@ -11366,7 +11485,13 @@ var statusCommand = new Command6("status").description("Show project status from
11366
11485
  const hasTracking = existsSync8(summaryPath);
11367
11486
  const actuals = /* @__PURE__ */ new Map();
11368
11487
  if (hasTracking) {
11369
- const summary = JSON.parse(readFileSync9(summaryPath, "utf-8"));
11488
+ let summary;
11489
+ try {
11490
+ summary = JSON.parse(readFileSync9(summaryPath, "utf-8"));
11491
+ } catch {
11492
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
11493
+ return process.exit(1);
11494
+ }
11370
11495
  for (const s of summary.sessions) {
11371
11496
  if (!s.featureId || s.status === "untagged") continue;
11372
11497
  const existing = actuals.get(s.featureId) ?? { tokens: 0, sessions: 0 };
@@ -11832,399 +11957,542 @@ function applyStatsAttribution(sessions, cache) {
11832
11957
  }
11833
11958
 
11834
11959
  // src/commands/track.ts
11835
- var trackCommand = new Command7("track").description("Import Claude Code sessions and auto-tag to features").option("-p, --project <name>", "Project name").option("--registry <path>", "Path to registry.json").option("--sessions-dir <path>", "Path to sessions directory").option("--backfill", "Process all existing sessions (not just new)").action(
11960
+ import { confirm as confirm3 } from "@inquirer/prompts";
11961
+ var trackCommand = new Command7("track").description("Import Claude Code sessions and auto-tag to features").option("-p, --project <name>", "Project name").option("--registry <path>", "Path to registry.json").option("--sessions-dir <path>", "Path to sessions directory").option("--backfill", "Process all existing sessions (not just new)").option("--dry-run", "Show what would change without modifying files").option("-y, --yes", "Skip confirmation prompt").action(
11836
11962
  async (options) => {
11837
- const registryPath = options.registry ?? resolve10(process.cwd(), ".claude", "te", "registry.json");
11838
- if (!existsSync10(registryPath)) {
11963
+ try {
11964
+ const registryPath = options.registry ?? resolve10(process.cwd(), ".claude", "te", "registry.json");
11965
+ if (!existsSync10(registryPath)) {
11966
+ console.log(
11967
+ chalk8.yellow(
11968
+ "No registry found. Run `fathom analyze <spec> --project <name>` first."
11969
+ )
11970
+ );
11971
+ return;
11972
+ }
11973
+ let registry;
11974
+ try {
11975
+ registry = JSON.parse(readFileSync11(registryPath, "utf-8"));
11976
+ } catch {
11977
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
11978
+ return process.exit(1);
11979
+ }
11980
+ const projectName = options.project ?? registry.project ?? getProjectName();
11981
+ console.log(chalk8.dim("Scanning for sessions..."));
11982
+ const sessionFiles = findSessionFiles(options.sessionsDir);
11983
+ if (sessionFiles.length === 0) {
11984
+ console.log(
11985
+ chalk8.yellow("No session files found. Run some Claude Code sessions first.")
11986
+ );
11987
+ return;
11988
+ }
11989
+ const trackingDir = resolve10(
11990
+ process.cwd(),
11991
+ ".claude",
11992
+ "te",
11993
+ "tracking",
11994
+ "sessions"
11995
+ );
11996
+ const summaryPath = resolve10(
11997
+ process.cwd(),
11998
+ ".claude",
11999
+ "te",
12000
+ "tracking",
12001
+ "summary.json"
12002
+ );
12003
+ const trackedIds = /* @__PURE__ */ new Set();
12004
+ if (!options.backfill && existsSync10(summaryPath)) {
12005
+ let summary;
12006
+ try {
12007
+ summary = JSON.parse(readFileSync11(summaryPath, "utf-8"));
12008
+ } catch {
12009
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
12010
+ process.exit(1);
12011
+ }
12012
+ for (const s of summary.sessions ?? []) {
12013
+ trackedIds.add(s.sessionId);
12014
+ }
12015
+ }
12016
+ const results = [];
12017
+ for (const file of sessionFiles) {
12018
+ const session = parseSessionFile(file);
12019
+ if (!session) continue;
12020
+ if (trackedIds.has(session.sessionId)) continue;
12021
+ const tag = autoTag(session, registry.features);
12022
+ results.push({ session, tag });
12023
+ }
12024
+ if (results.length === 0) {
12025
+ console.log(
12026
+ chalk8.dim("No new sessions to process.")
12027
+ );
12028
+ return;
12029
+ }
12030
+ const statsCache = readStatsCache();
12031
+ let attributionSummary = null;
12032
+ if (statsCache) {
12033
+ const allSessions = results.map((r) => r.session);
12034
+ const attribution = applyStatsAttribution(allSessions, statsCache);
12035
+ if (attribution.upgraded > 0) {
12036
+ attributionSummary = `Stats-cache: ${attribution.upgraded} sessions upgraded, ${attribution.unchanged} had API data, ${attribution.unattributed} unattributed`;
12037
+ }
12038
+ }
11839
12039
  console.log(
11840
- chalk8.yellow(
11841
- "No registry found. Run `fathom analyze <spec> --project <name>` first."
12040
+ chalk8.bold(`
12041
+ Fathom \u2014 Track: ${projectName} (${results.length} sessions)`)
12042
+ );
12043
+ console.log(chalk8.dim("\u2500".repeat(65)));
12044
+ const table = new Table5({
12045
+ head: [
12046
+ chalk8.white("Session"),
12047
+ chalk8.white("Feature"),
12048
+ chalk8.white("Confidence"),
12049
+ chalk8.white("Tag"),
12050
+ chalk8.white("Tokens"),
12051
+ chalk8.white("Token Src")
12052
+ ],
12053
+ colAligns: ["left", "left", "center", "center", "right", "center"]
12054
+ });
12055
+ let totalTokens = 0;
12056
+ let autoTagged = 0;
12057
+ let likely = 0;
12058
+ let untagged = 0;
12059
+ for (const { session, tag } of results) {
12060
+ const tokens = session.inputTokens + session.outputTokens;
12061
+ totalTokens += tokens;
12062
+ const featureLabel = tag.featureId ? registry.features.find((f) => f.id === tag.featureId)?.name ?? tag.featureId : chalk8.dim("\u2014");
12063
+ const confidenceColor = tag.status === "auto-tagged" ? chalk8.green : tag.status === "likely" ? chalk8.yellow : chalk8.dim;
12064
+ if (tag.status === "auto-tagged") autoTagged++;
12065
+ else if (tag.status === "likely") likely++;
12066
+ else untagged++;
12067
+ const srcColor = session.tokenSource === "api" ? chalk8.green : session.tokenSource === "stats-cache" ? chalk8.cyan : session.tokenSource === "tokenizer" ? chalk8.yellow : chalk8.dim;
12068
+ table.push([
12069
+ session.sessionId.slice(0, 12) + "\u2026",
12070
+ featureLabel,
12071
+ confidenceColor(`${(tag.confidence * 100).toFixed(0)}%`),
12072
+ tag.source,
12073
+ tokens.toLocaleString(),
12074
+ srcColor(session.tokenSource)
12075
+ ]);
12076
+ }
12077
+ console.log(table.toString());
12078
+ console.log(
12079
+ chalk8.dim(
12080
+ `
12081
+ Total: ${totalTokens.toLocaleString()} tokens across ${results.length} sessions`
11842
12082
  )
11843
12083
  );
11844
- return;
11845
- }
11846
- const registry = JSON.parse(
11847
- readFileSync11(registryPath, "utf-8")
11848
- );
11849
- const projectName = options.project ?? registry.project ?? getProjectName();
11850
- console.log(chalk8.dim("Scanning for sessions..."));
11851
- const sessionFiles = findSessionFiles(options.sessionsDir);
11852
- if (sessionFiles.length === 0) {
11853
12084
  console.log(
11854
- chalk8.yellow("No session files found. Run some Claude Code sessions first.")
12085
+ chalk8.dim(
12086
+ `Tags: ${autoTagged} auto-tagged, ${likely} likely, ${untagged} untagged`
12087
+ )
11855
12088
  );
11856
- return;
12089
+ if (attributionSummary) {
12090
+ console.log(chalk8.cyan(` ${attributionSummary}`));
12091
+ }
12092
+ if (options.dryRun) {
12093
+ console.log(chalk8.dim(`
12094
+ [dry-run] Would write ${results.length} sessions to tracking. No files modified.`));
12095
+ return;
12096
+ }
12097
+ if (!options.yes) {
12098
+ const proceed = await confirm3({
12099
+ message: `Track ${results.length} sessions and update summary? (use --yes to skip)`,
12100
+ default: true
12101
+ });
12102
+ if (!proceed) {
12103
+ console.log(chalk8.dim("\n Aborted."));
12104
+ return;
12105
+ }
12106
+ }
12107
+ mkdirSync8(trackingDir, { recursive: true });
12108
+ const sessionRecords = results.map(({ session, tag }) => ({
12109
+ sessionId: session.sessionId,
12110
+ featureId: tag.featureId,
12111
+ confidence: tag.confidence,
12112
+ source: tag.source,
12113
+ status: tag.status,
12114
+ inputTokens: session.inputTokens,
12115
+ outputTokens: session.outputTokens,
12116
+ cacheReadTokens: session.cacheReadTokens,
12117
+ model: session.model,
12118
+ sessionStart: session.sessionStart,
12119
+ sessionEnd: session.sessionEnd,
12120
+ activeMinutes: session.activeMinutes,
12121
+ tokenSource: session.tokenSource,
12122
+ trackedAt: (/* @__PURE__ */ new Date()).toISOString()
12123
+ }));
12124
+ let existing = { sessions: [] };
12125
+ if (existsSync10(summaryPath)) {
12126
+ try {
12127
+ existing = JSON.parse(readFileSync11(summaryPath, "utf-8"));
12128
+ } catch {
12129
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
12130
+ process.exit(1);
12131
+ }
12132
+ }
12133
+ existing.sessions.push(...sessionRecords);
12134
+ writeFileSync7(
12135
+ summaryPath,
12136
+ JSON.stringify(
12137
+ {
12138
+ project: projectName,
12139
+ sessions: existing.sessions,
12140
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
12141
+ },
12142
+ null,
12143
+ 2
12144
+ )
12145
+ );
12146
+ console.log(chalk8.dim(`
12147
+ Tracking saved: ${summaryPath}`));
12148
+ if (isConvexConnected()) {
12149
+ const syncCount = await withSpinner("Syncing to Convex...", async () => {
12150
+ let count = 0;
12151
+ for (const { session, tag } of results) {
12152
+ const synced = await syncActual({
12153
+ projectName,
12154
+ sessionId: session.sessionId,
12155
+ provider: "claude",
12156
+ model: session.model,
12157
+ inputTokens: session.inputTokens,
12158
+ outputTokens: session.outputTokens,
12159
+ cacheReadTokens: session.cacheReadTokens,
12160
+ cacheCreateTokens: 0,
12161
+ toolCalls: 0,
12162
+ estimatedCost: 0,
12163
+ sessionStart: session.sessionStart,
12164
+ sessionEnd: session.sessionEnd,
12165
+ activeMinutes: session.activeMinutes,
12166
+ featureId: tag.featureId,
12167
+ tagConfidence: tag.confidence,
12168
+ tagSource: tag.source,
12169
+ tagStatus: tag.status
12170
+ });
12171
+ if (synced) count++;
12172
+ }
12173
+ return count;
12174
+ });
12175
+ logConvexStatus(syncCount > 0);
12176
+ if (syncCount > 0) {
12177
+ console.log(chalk8.dim(` ${syncCount} sessions synced to Convex.`));
12178
+ }
12179
+ } else {
12180
+ logConvexStatus(false);
12181
+ }
12182
+ } catch (err) {
12183
+ if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
12184
+ console.log(chalk8.dim("\n Exiting.\n"));
12185
+ return;
12186
+ }
12187
+ throw err;
11857
12188
  }
11858
- const trackingDir = resolve10(
11859
- process.cwd(),
11860
- ".claude",
11861
- "te",
11862
- "tracking",
11863
- "sessions"
12189
+ }
12190
+ );
12191
+
12192
+ // src/commands/reconcile.ts
12193
+ import { Command as Command8 } from "commander";
12194
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, existsSync as existsSync11, mkdirSync as mkdirSync9 } from "fs";
12195
+ import { resolve as resolve11 } from "path";
12196
+ import chalk9 from "chalk";
12197
+ import Table6 from "cli-table3";
12198
+ import { confirm as confirm4 } from "@inquirer/prompts";
12199
+ function computeCalibrationEntries(features, actuals, sessions) {
12200
+ const entries = [];
12201
+ for (const feature of features) {
12202
+ const actual = actuals.get(feature.id);
12203
+ if (!actual) continue;
12204
+ const featureSessions = sessions.filter(
12205
+ (s) => s.featureId === feature.id && s.status !== "untagged"
11864
12206
  );
11865
- const summaryPath = resolve10(
12207
+ const dominantModel = featureSessions.length > 0 ? featureSessions[0].model : "unknown";
12208
+ entries.push({
12209
+ model: dominantModel,
12210
+ taskType: "mixed",
12211
+ // features span multiple task types
12212
+ complexity: "M",
12213
+ // default; could be refined with registry metadata
12214
+ estimatedTokens: feature.estimated_tokens,
12215
+ actualTokens: actual.tokens,
12216
+ accuracy: feature.estimated_tokens > 0 ? actual.tokens / feature.estimated_tokens : 0,
12217
+ timestamp: Date.now()
12218
+ });
12219
+ }
12220
+ return entries;
12221
+ }
12222
+ function persistCalibration(entries, projectDir = process.cwd()) {
12223
+ const calPath = resolve11(projectDir, ".fathom", "store", "calibration.json");
12224
+ mkdirSync9(resolve11(projectDir, ".fathom", "store"), { recursive: true });
12225
+ let existing = { entries: [] };
12226
+ try {
12227
+ existing = JSON.parse(readFileSync12(calPath, "utf-8"));
12228
+ } catch {
12229
+ }
12230
+ existing.entries.push(...entries);
12231
+ writeFileSync8(calPath, JSON.stringify(existing, null, 2));
12232
+ }
12233
+ var reconcileCommand = new Command8("reconcile").description("Compare estimates vs actuals per feature").option("-p, --project <name>", "Project name").option("--registry <path>", "Path to registry.json").option("--dry-run", "Show reconciliation without syncing").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
12234
+ try {
12235
+ const registryPath = options.registry ?? resolve11(process.cwd(), ".claude", "te", "registry.json");
12236
+ const summaryPath = resolve11(
11866
12237
  process.cwd(),
11867
12238
  ".claude",
11868
12239
  "te",
11869
12240
  "tracking",
11870
12241
  "summary.json"
11871
12242
  );
11872
- const trackedIds = /* @__PURE__ */ new Set();
11873
- if (!options.backfill && existsSync10(summaryPath)) {
11874
- const summary = JSON.parse(readFileSync11(summaryPath, "utf-8"));
11875
- for (const s of summary.sessions ?? []) {
11876
- trackedIds.add(s.sessionId);
11877
- }
11878
- }
11879
- const results = [];
11880
- for (const file of sessionFiles) {
11881
- const session = parseSessionFile(file);
11882
- if (!session) continue;
11883
- if (trackedIds.has(session.sessionId)) continue;
11884
- const tag = autoTag(session, registry.features);
11885
- results.push({ session, tag });
12243
+ if (!existsSync11(registryPath)) {
12244
+ console.log(
12245
+ chalk9.yellow(
12246
+ "No registry found. Run `fathom analyze <spec> --project <name>` first."
12247
+ )
12248
+ );
12249
+ return;
11886
12250
  }
11887
- if (results.length === 0) {
12251
+ if (!existsSync11(summaryPath)) {
11888
12252
  console.log(
11889
- chalk8.dim("No new sessions to process.")
12253
+ chalk9.yellow(
12254
+ "No tracking data found. Run `fathom track --project <name>` first."
12255
+ )
11890
12256
  );
11891
12257
  return;
11892
12258
  }
11893
- const statsCache = readStatsCache();
11894
- let attributionSummary = null;
11895
- if (statsCache) {
11896
- const allSessions = results.map((r) => r.session);
11897
- const attribution = applyStatsAttribution(allSessions, statsCache);
11898
- if (attribution.upgraded > 0) {
11899
- attributionSummary = `Stats-cache: ${attribution.upgraded} sessions upgraded, ${attribution.unchanged} had API data, ${attribution.unattributed} unattributed`;
11900
- }
12259
+ let registry;
12260
+ try {
12261
+ registry = JSON.parse(readFileSync12(registryPath, "utf-8"));
12262
+ } catch {
12263
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
12264
+ return process.exit(1);
12265
+ }
12266
+ let summary;
12267
+ try {
12268
+ summary = JSON.parse(readFileSync12(summaryPath, "utf-8"));
12269
+ } catch {
12270
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
12271
+ return process.exit(1);
12272
+ }
12273
+ const projectName = options.project ?? registry.project ?? getProjectName();
12274
+ const data = loadData();
12275
+ const actuals = /* @__PURE__ */ new Map();
12276
+ for (const session of summary.sessions) {
12277
+ if (!session.featureId || session.status === "untagged") continue;
12278
+ const existing = actuals.get(session.featureId) ?? {
12279
+ tokens: 0,
12280
+ cost: 0,
12281
+ sessions: 0,
12282
+ minutes: 0
12283
+ };
12284
+ const totalTokens = session.inputTokens + session.outputTokens;
12285
+ const pricing = getModelPricing(data.models, session.model);
12286
+ const cost = pricing ? session.inputTokens / 1e6 * pricing.input_per_mtok + session.outputTokens / 1e6 * pricing.output_per_mtok + session.cacheReadTokens / 1e6 * pricing.cache_read_per_mtok : 0;
12287
+ existing.tokens += totalTokens;
12288
+ existing.cost += cost;
12289
+ existing.sessions += 1;
12290
+ existing.minutes += session.activeMinutes ?? 0;
12291
+ actuals.set(session.featureId, existing);
11901
12292
  }
11902
12293
  console.log(
11903
- chalk8.bold(`
11904
- Fathom \u2014 Track: ${projectName} (${results.length} sessions)`)
12294
+ chalk9.bold(`
12295
+ Fathom \u2014 Reconcile: ${projectName}`)
11905
12296
  );
11906
- console.log(chalk8.dim("\u2500".repeat(65)));
11907
- const table = new Table5({
12297
+ console.log(chalk9.dim("\u2500".repeat(75)));
12298
+ const table = new Table6({
11908
12299
  head: [
11909
- chalk8.white("Session"),
11910
- chalk8.white("Feature"),
11911
- chalk8.white("Confidence"),
11912
- chalk8.white("Tag"),
11913
- chalk8.white("Tokens"),
11914
- chalk8.white("Token Src")
12300
+ chalk9.white("Feature"),
12301
+ chalk9.white("Est. Tokens"),
12302
+ chalk9.white("Act. Tokens"),
12303
+ chalk9.white("Drift"),
12304
+ chalk9.white("Act. Cost"),
12305
+ chalk9.white("Sessions")
11915
12306
  ],
11916
- colAligns: ["left", "left", "center", "center", "right", "center"]
12307
+ colAligns: ["left", "right", "right", "center", "right", "center"]
11917
12308
  });
11918
- let totalTokens = 0;
11919
- let autoTagged = 0;
11920
- let likely = 0;
11921
- let untagged = 0;
11922
- for (const { session, tag } of results) {
11923
- const tokens = session.inputTokens + session.outputTokens;
11924
- totalTokens += tokens;
11925
- const featureLabel = tag.featureId ? registry.features.find((f) => f.id === tag.featureId)?.name ?? tag.featureId : chalk8.dim("\u2014");
11926
- const confidenceColor = tag.status === "auto-tagged" ? chalk8.green : tag.status === "likely" ? chalk8.yellow : chalk8.dim;
11927
- if (tag.status === "auto-tagged") autoTagged++;
11928
- else if (tag.status === "likely") likely++;
11929
- else untagged++;
11930
- const srcColor = session.tokenSource === "api" ? chalk8.green : session.tokenSource === "stats-cache" ? chalk8.cyan : session.tokenSource === "tokenizer" ? chalk8.yellow : chalk8.dim;
11931
- table.push([
11932
- session.sessionId.slice(0, 12) + "\u2026",
11933
- featureLabel,
11934
- confidenceColor(`${(tag.confidence * 100).toFixed(0)}%`),
11935
- tag.source,
11936
- tokens.toLocaleString(),
11937
- srcColor(session.tokenSource)
11938
- ]);
12309
+ let totalEstimated = 0;
12310
+ let totalActual = 0;
12311
+ let totalCost = 0;
12312
+ for (const feature of registry.features) {
12313
+ const actual = actuals.get(feature.id);
12314
+ const estimated = feature.estimated_tokens;
12315
+ totalEstimated += estimated;
12316
+ if (actual) {
12317
+ totalActual += actual.tokens;
12318
+ totalCost += actual.cost;
12319
+ const drift = (actual.tokens - estimated) / estimated * 100;
12320
+ const driftLabel = drift > 20 ? chalk9.red(`+${drift.toFixed(0)}%`) : drift < -20 ? chalk9.green(`${drift.toFixed(0)}%`) : chalk9.white(`${drift >= 0 ? "+" : ""}${drift.toFixed(0)}%`);
12321
+ table.push([
12322
+ feature.name,
12323
+ estimated.toLocaleString(),
12324
+ actual.tokens.toLocaleString(),
12325
+ driftLabel,
12326
+ `$${actual.cost.toFixed(2)}`,
12327
+ actual.sessions.toString()
12328
+ ]);
12329
+ } else {
12330
+ table.push([
12331
+ feature.name,
12332
+ estimated.toLocaleString(),
12333
+ chalk9.dim("\u2014"),
12334
+ chalk9.dim("\u2014"),
12335
+ chalk9.dim("\u2014"),
12336
+ chalk9.dim("0")
12337
+ ]);
12338
+ }
11939
12339
  }
11940
12340
  console.log(table.toString());
12341
+ const overallDrift = totalEstimated > 0 ? (totalActual - totalEstimated) / totalEstimated * 100 : 0;
12342
+ const driftColor = Math.abs(overallDrift) > 20 ? overallDrift > 0 ? chalk9.red : chalk9.green : chalk9.white;
11941
12343
  console.log(
11942
- chalk8.dim(
12344
+ chalk9.bold(
11943
12345
  `
11944
- Total: ${totalTokens.toLocaleString()} tokens across ${results.length} sessions`
12346
+ Overall: ${totalActual.toLocaleString()} / ${totalEstimated.toLocaleString()} tokens \u2014 ` + driftColor(
12347
+ `${overallDrift >= 0 ? "+" : ""}${overallDrift.toFixed(1)}% drift`
12348
+ )
11945
12349
  )
11946
12350
  );
12351
+ console.log(chalk9.dim(`Total actual cost: $${totalCost.toFixed(2)}`));
11947
12352
  console.log(
11948
- chalk8.dim(
11949
- `Tags: ${autoTagged} auto-tagged, ${likely} likely, ${untagged} untagged`
12353
+ chalk9.dim(
12354
+ `Features with data: ${actuals.size} / ${registry.features.length}`
11950
12355
  )
11951
12356
  );
11952
- if (attributionSummary) {
11953
- console.log(chalk8.cyan(` ${attributionSummary}`));
11954
- }
11955
- mkdirSync8(trackingDir, { recursive: true });
11956
- const sessionRecords = results.map(({ session, tag }) => ({
11957
- sessionId: session.sessionId,
11958
- featureId: tag.featureId,
11959
- confidence: tag.confidence,
11960
- source: tag.source,
11961
- status: tag.status,
11962
- inputTokens: session.inputTokens,
11963
- outputTokens: session.outputTokens,
11964
- cacheReadTokens: session.cacheReadTokens,
11965
- model: session.model,
11966
- sessionStart: session.sessionStart,
11967
- sessionEnd: session.sessionEnd,
11968
- activeMinutes: session.activeMinutes,
11969
- tokenSource: session.tokenSource,
11970
- trackedAt: (/* @__PURE__ */ new Date()).toISOString()
11971
- }));
11972
- let existing = { sessions: [] };
11973
- if (existsSync10(summaryPath)) {
11974
- existing = JSON.parse(readFileSync11(summaryPath, "utf-8"));
12357
+ const config = readProjectConfig();
12358
+ const adminKey = resolveAdminKey(config?.anthropicAdminKey ?? void 0);
12359
+ let adminReport = null;
12360
+ if (adminKey) {
12361
+ const sessions = summary.sessions;
12362
+ const starts = sessions.map((s) => s.sessionStart).filter((t) => typeof t === "number" && t > 0);
12363
+ if (starts.length > 0) {
12364
+ const earliest = new Date(Math.min(...starts));
12365
+ const latest = /* @__PURE__ */ new Date();
12366
+ earliest.setHours(0, 0, 0, 0);
12367
+ adminReport = await withSpinner(
12368
+ "Fetching admin usage data...",
12369
+ async () => fetchAdminUsage({
12370
+ adminKey,
12371
+ startingAt: earliest.toISOString(),
12372
+ endingAt: latest.toISOString(),
12373
+ bucketWidth: "1d",
12374
+ groupBy: ["model"]
12375
+ })
12376
+ );
12377
+ if (adminReport) {
12378
+ const billedTotal = adminReport.totalInputTokens + adminReport.totalOutputTokens;
12379
+ const gap = billedTotal - totalActual;
12380
+ const gapPct = totalActual > 0 ? gap / totalActual * 100 : 0;
12381
+ console.log(chalk9.bold("\n Billed vs Tracked (Admin API)"));
12382
+ console.log(chalk9.dim(" " + "\u2500".repeat(45)));
12383
+ console.log(
12384
+ chalk9.dim(
12385
+ ` Tracked: ${totalActual.toLocaleString()} tokens`
12386
+ )
12387
+ );
12388
+ console.log(
12389
+ chalk9.dim(
12390
+ ` Billed: ${billedTotal.toLocaleString()} tokens`
12391
+ )
12392
+ );
12393
+ const gapColor = Math.abs(gapPct) > 20 ? chalk9.red : chalk9.yellow;
12394
+ console.log(
12395
+ gapColor(
12396
+ ` Gap: ${gap.toLocaleString()} tokens (${gapPct >= 0 ? "+" : ""}${gapPct.toFixed(1)}%)`
12397
+ )
12398
+ );
12399
+ if (adminReport.totalCacheReadTokens > 0) {
12400
+ console.log(
12401
+ chalk9.dim(
12402
+ ` Cache: ${adminReport.totalCacheReadTokens.toLocaleString()} cache-read, ${adminReport.totalCacheCreationTokens.toLocaleString()} cache-create`
12403
+ )
12404
+ );
12405
+ }
12406
+ } else {
12407
+ console.log(
12408
+ chalk9.dim("\n Admin API: Could not fetch usage data (check key/permissions)")
12409
+ );
12410
+ }
12411
+ }
11975
12412
  }
11976
- existing.sessions.push(...sessionRecords);
11977
- writeFileSync7(
11978
- summaryPath,
11979
- JSON.stringify(
11980
- {
11981
- project: projectName,
11982
- sessions: existing.sessions,
11983
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
11984
- },
11985
- null,
11986
- 2
11987
- )
12413
+ const calibrationEntries = computeCalibrationEntries(
12414
+ registry.features,
12415
+ actuals,
12416
+ summary.sessions
11988
12417
  );
11989
- console.log(chalk8.dim(`
11990
- Tracking saved: ${summaryPath}`));
11991
- if (isConvexConnected()) {
11992
- let syncCount = 0;
11993
- for (const { session, tag } of results) {
11994
- const synced = await syncActual({
11995
- projectName,
11996
- sessionId: session.sessionId,
11997
- provider: "claude",
11998
- model: session.model,
11999
- inputTokens: session.inputTokens,
12000
- outputTokens: session.outputTokens,
12001
- cacheReadTokens: session.cacheReadTokens,
12002
- cacheCreateTokens: 0,
12003
- toolCalls: 0,
12004
- estimatedCost: 0,
12005
- sessionStart: session.sessionStart,
12006
- sessionEnd: session.sessionEnd,
12007
- activeMinutes: session.activeMinutes,
12008
- featureId: tag.featureId,
12009
- tagConfidence: tag.confidence,
12010
- tagSource: tag.source,
12011
- tagStatus: tag.status
12012
- });
12013
- if (synced) syncCount++;
12014
- }
12015
- logConvexStatus(syncCount > 0);
12016
- if (syncCount > 0) {
12017
- console.log(chalk8.dim(` ${syncCount} sessions synced to Convex.`));
12018
- }
12019
- } else {
12020
- logConvexStatus(false);
12418
+ if (calibrationEntries.length > 0) {
12419
+ const avgAccuracy = calibrationEntries.reduce((sum, e) => sum + e.accuracy, 0) / calibrationEntries.length;
12420
+ const pct = Math.round(
12421
+ Math.min(avgAccuracy, 1 / avgAccuracy) * 100
12422
+ );
12423
+ console.log(
12424
+ chalk9.bold(
12425
+ `
12426
+ Calibration: ${calibrationEntries.length} features reconciled. Average accuracy: ${pct}%.`
12427
+ )
12428
+ );
12021
12429
  }
12022
- }
12023
- );
12024
-
12025
- // src/commands/reconcile.ts
12026
- import { Command as Command8 } from "commander";
12027
- import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
12028
- import { resolve as resolve11 } from "path";
12029
- import chalk9 from "chalk";
12030
- import Table6 from "cli-table3";
12031
- var reconcileCommand = new Command8("reconcile").description("Compare estimates vs actuals per feature").option("-p, --project <name>", "Project name").option("--registry <path>", "Path to registry.json").action(async (options) => {
12032
- const registryPath = options.registry ?? resolve11(process.cwd(), ".claude", "te", "registry.json");
12033
- const summaryPath = resolve11(
12034
- process.cwd(),
12035
- ".claude",
12036
- "te",
12037
- "tracking",
12038
- "summary.json"
12039
- );
12040
- if (!existsSync11(registryPath)) {
12041
- console.log(
12042
- chalk9.yellow(
12043
- "No registry found. Run `fathom analyze <spec> --project <name>` first."
12044
- )
12045
- );
12046
- return;
12047
- }
12048
- if (!existsSync11(summaryPath)) {
12049
- console.log(
12050
- chalk9.yellow(
12051
- "No tracking data found. Run `fathom track --project <name>` first."
12052
- )
12053
- );
12054
- return;
12055
- }
12056
- const registry = JSON.parse(readFileSync12(registryPath, "utf-8"));
12057
- const summary = JSON.parse(readFileSync12(summaryPath, "utf-8"));
12058
- const projectName = options.project ?? registry.project ?? getProjectName();
12059
- const data = loadData();
12060
- const actuals = /* @__PURE__ */ new Map();
12061
- for (const session of summary.sessions) {
12062
- if (!session.featureId || session.status === "untagged") continue;
12063
- const existing = actuals.get(session.featureId) ?? {
12064
- tokens: 0,
12065
- cost: 0,
12066
- sessions: 0,
12067
- minutes: 0
12068
- };
12069
- const totalTokens = session.inputTokens + session.outputTokens;
12070
- const pricing = getModelPricing(data.models, session.model);
12071
- const cost = pricing ? session.inputTokens / 1e6 * pricing.input_per_mtok + session.outputTokens / 1e6 * pricing.output_per_mtok + session.cacheReadTokens / 1e6 * pricing.cache_read_per_mtok : 0;
12072
- existing.tokens += totalTokens;
12073
- existing.cost += cost;
12074
- existing.sessions += 1;
12075
- existing.minutes += session.activeMinutes ?? 0;
12076
- actuals.set(session.featureId, existing);
12077
- }
12078
- console.log(
12079
- chalk9.bold(`
12080
- Fathom \u2014 Reconcile: ${projectName}`)
12081
- );
12082
- console.log(chalk9.dim("\u2500".repeat(75)));
12083
- const table = new Table6({
12084
- head: [
12085
- chalk9.white("Feature"),
12086
- chalk9.white("Est. Tokens"),
12087
- chalk9.white("Act. Tokens"),
12088
- chalk9.white("Drift"),
12089
- chalk9.white("Act. Cost"),
12090
- chalk9.white("Sessions")
12091
- ],
12092
- colAligns: ["left", "right", "right", "center", "right", "center"]
12093
- });
12094
- let totalEstimated = 0;
12095
- let totalActual = 0;
12096
- let totalCost = 0;
12097
- for (const feature of registry.features) {
12098
- const actual = actuals.get(feature.id);
12099
- const estimated = feature.estimated_tokens;
12100
- totalEstimated += estimated;
12101
- if (actual) {
12102
- totalActual += actual.tokens;
12103
- totalCost += actual.cost;
12104
- const drift = (actual.tokens - estimated) / estimated * 100;
12105
- const driftLabel = drift > 20 ? chalk9.red(`+${drift.toFixed(0)}%`) : drift < -20 ? chalk9.green(`${drift.toFixed(0)}%`) : chalk9.white(`${drift >= 0 ? "+" : ""}${drift.toFixed(0)}%`);
12106
- table.push([
12107
- feature.name,
12108
- estimated.toLocaleString(),
12109
- actual.tokens.toLocaleString(),
12110
- driftLabel,
12111
- `$${actual.cost.toFixed(2)}`,
12112
- actual.sessions.toString()
12113
- ]);
12114
- } else {
12115
- table.push([
12116
- feature.name,
12117
- estimated.toLocaleString(),
12118
- chalk9.dim("\u2014"),
12119
- chalk9.dim("\u2014"),
12120
- chalk9.dim("\u2014"),
12121
- chalk9.dim("0")
12122
- ]);
12430
+ if (!options.dryRun && calibrationEntries.length > 0) {
12431
+ persistCalibration(calibrationEntries);
12432
+ console.log(
12433
+ chalk9.dim(
12434
+ ` Calibration data saved to .fathom/store/calibration.json`
12435
+ )
12436
+ );
12123
12437
  }
12124
- }
12125
- console.log(table.toString());
12126
- const overallDrift = totalEstimated > 0 ? (totalActual - totalEstimated) / totalEstimated * 100 : 0;
12127
- const driftColor = Math.abs(overallDrift) > 20 ? overallDrift > 0 ? chalk9.red : chalk9.green : chalk9.white;
12128
- console.log(
12129
- chalk9.bold(
12130
- `
12131
- Overall: ${totalActual.toLocaleString()} / ${totalEstimated.toLocaleString()} tokens \u2014 ` + driftColor(
12132
- `${overallDrift >= 0 ? "+" : ""}${overallDrift.toFixed(1)}% drift`
12133
- )
12134
- )
12135
- );
12136
- console.log(chalk9.dim(`Total actual cost: $${totalCost.toFixed(2)}`));
12137
- console.log(
12138
- chalk9.dim(
12139
- `Features with data: ${actuals.size} / ${registry.features.length}`
12140
- )
12141
- );
12142
- const config = readProjectConfig();
12143
- const adminKey = resolveAdminKey(config?.anthropicAdminKey ?? void 0);
12144
- let adminReport = null;
12145
- if (adminKey) {
12146
- const sessions = summary.sessions;
12147
- const starts = sessions.map((s) => s.sessionStart).filter((t) => typeof t === "number" && t > 0);
12148
- if (starts.length > 0) {
12149
- const earliest = new Date(Math.min(...starts));
12150
- const latest = /* @__PURE__ */ new Date();
12151
- earliest.setHours(0, 0, 0, 0);
12152
- adminReport = await fetchAdminUsage({
12153
- adminKey,
12154
- startingAt: earliest.toISOString(),
12155
- endingAt: latest.toISOString(),
12156
- bucketWidth: "1d",
12157
- groupBy: ["model"]
12438
+ if (options.dryRun) {
12439
+ console.log(chalk9.dim(`
12440
+ [dry-run] Would sync reconciliation to Convex. No changes made.`));
12441
+ return;
12442
+ }
12443
+ if (!options.yes) {
12444
+ const proceed = await confirm4({
12445
+ message: "Sync reconciliation data? (use --yes to skip)",
12446
+ default: true
12158
12447
  });
12159
- if (adminReport) {
12160
- const billedTotal = adminReport.totalInputTokens + adminReport.totalOutputTokens;
12161
- const gap = billedTotal - totalActual;
12162
- const gapPct = totalActual > 0 ? gap / totalActual * 100 : 0;
12163
- console.log(chalk9.bold("\n Billed vs Tracked (Admin API)"));
12164
- console.log(chalk9.dim(" " + "\u2500".repeat(45)));
12165
- console.log(
12166
- chalk9.dim(
12167
- ` Tracked: ${totalActual.toLocaleString()} tokens`
12168
- )
12169
- );
12170
- console.log(
12171
- chalk9.dim(
12172
- ` Billed: ${billedTotal.toLocaleString()} tokens`
12173
- )
12174
- );
12175
- const gapColor = Math.abs(gapPct) > 20 ? chalk9.red : chalk9.yellow;
12176
- console.log(
12177
- gapColor(
12178
- ` Gap: ${gap.toLocaleString()} tokens (${gapPct >= 0 ? "+" : ""}${gapPct.toFixed(1)}%)`
12179
- )
12180
- );
12181
- if (adminReport.totalCacheReadTokens > 0) {
12182
- console.log(
12183
- chalk9.dim(
12184
- ` Cache: ${adminReport.totalCacheReadTokens.toLocaleString()} cache-read, ${adminReport.totalCacheCreationTokens.toLocaleString()} cache-create`
12185
- )
12186
- );
12187
- }
12188
- } else {
12189
- console.log(
12190
- chalk9.dim("\n Admin API: Could not fetch usage data (check key/permissions)")
12191
- );
12448
+ if (!proceed) {
12449
+ console.log(chalk9.dim("\n Aborted."));
12450
+ return;
12192
12451
  }
12193
12452
  }
12453
+ const overallAccuracy = totalEstimated > 0 ? 1 - Math.abs(totalActual - totalEstimated) / totalEstimated : 0;
12454
+ const featureDeltas = registry.features.filter((f) => actuals.has(f.id)).map((f) => {
12455
+ const actual = actuals.get(f.id);
12456
+ const delta = actual.tokens - f.estimated_tokens;
12457
+ const accuracy = f.estimated_tokens > 0 ? 1 - Math.abs(delta) / f.estimated_tokens : 0;
12458
+ return {
12459
+ featureId: f.id,
12460
+ featureName: f.name,
12461
+ estimatedTokens: f.estimated_tokens,
12462
+ actualTokens: actual.tokens,
12463
+ delta,
12464
+ accuracy,
12465
+ modelRecommended: "sonnet",
12466
+ modelUsed: "sonnet",
12467
+ sessionsCount: actual.sessions,
12468
+ activeMinutes: actual.minutes,
12469
+ tagQuality: { autoTagged: actual.sessions, likelyTagged: 0, manualTagged: 0 },
12470
+ insights: delta > 0 ? [`Over estimate by ${(delta / f.estimated_tokens * 100).toFixed(0)}%`] : [`Under estimate by ${(Math.abs(delta) / f.estimated_tokens * 100).toFixed(0)}%`]
12471
+ };
12472
+ });
12473
+ const totalSessions = Array.from(actuals.values()).reduce((s, a) => s + a.sessions, 0);
12474
+ const autoTagged = summary.sessions.filter(
12475
+ (s) => s.status === "auto-tagged"
12476
+ ).length;
12477
+ const autoTagAccuracy = totalSessions > 0 ? autoTagged / totalSessions : 0;
12478
+ const synced = await withSpinner(
12479
+ "Syncing reconciliation...",
12480
+ async () => syncReconciliation({
12481
+ projectName,
12482
+ featureDeltas,
12483
+ overallAccuracy,
12484
+ autoTagAccuracy,
12485
+ suggestedAdjustments: []
12486
+ })
12487
+ );
12488
+ logConvexStatus(synced);
12489
+ } catch (err) {
12490
+ if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
12491
+ console.log(chalk9.dim("\n Exiting.\n"));
12492
+ return;
12493
+ }
12494
+ throw err;
12194
12495
  }
12195
- const overallAccuracy = totalEstimated > 0 ? 1 - Math.abs(totalActual - totalEstimated) / totalEstimated : 0;
12196
- const featureDeltas = registry.features.filter((f) => actuals.has(f.id)).map((f) => {
12197
- const actual = actuals.get(f.id);
12198
- const delta = actual.tokens - f.estimated_tokens;
12199
- const accuracy = f.estimated_tokens > 0 ? 1 - Math.abs(delta) / f.estimated_tokens : 0;
12200
- return {
12201
- featureId: f.id,
12202
- featureName: f.name,
12203
- estimatedTokens: f.estimated_tokens,
12204
- actualTokens: actual.tokens,
12205
- delta,
12206
- accuracy,
12207
- modelRecommended: "sonnet",
12208
- modelUsed: "sonnet",
12209
- sessionsCount: actual.sessions,
12210
- activeMinutes: actual.minutes,
12211
- tagQuality: { autoTagged: actual.sessions, likelyTagged: 0, manualTagged: 0 },
12212
- insights: delta > 0 ? [`Over estimate by ${(delta / f.estimated_tokens * 100).toFixed(0)}%`] : [`Under estimate by ${(Math.abs(delta) / f.estimated_tokens * 100).toFixed(0)}%`]
12213
- };
12214
- });
12215
- const totalSessions = Array.from(actuals.values()).reduce((s, a) => s + a.sessions, 0);
12216
- const autoTagged = summary.sessions.filter(
12217
- (s) => s.status === "auto-tagged"
12218
- ).length;
12219
- const autoTagAccuracy = totalSessions > 0 ? autoTagged / totalSessions : 0;
12220
- const synced = await syncReconciliation({
12221
- projectName,
12222
- featureDeltas,
12223
- overallAccuracy,
12224
- autoTagAccuracy,
12225
- suggestedAdjustments: []
12226
- });
12227
- logConvexStatus(synced);
12228
12496
  });
12229
12497
 
12230
12498
  // src/commands/calibrate.ts
@@ -12250,8 +12518,20 @@ var calibrateCommand = new Command9("calibrate").description("Show calibration d
12250
12518
  );
12251
12519
  return;
12252
12520
  }
12253
- const registry = JSON.parse(readFileSync13(registryPath, "utf-8"));
12254
- const summary = JSON.parse(readFileSync13(summaryPath, "utf-8"));
12521
+ let registry;
12522
+ try {
12523
+ registry = JSON.parse(readFileSync13(registryPath, "utf-8"));
12524
+ } catch {
12525
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
12526
+ return process.exit(1);
12527
+ }
12528
+ let summary;
12529
+ try {
12530
+ summary = JSON.parse(readFileSync13(summaryPath, "utf-8"));
12531
+ } catch {
12532
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
12533
+ return process.exit(1);
12534
+ }
12255
12535
  const projectName = options.project ?? registry.project ?? getProjectName();
12256
12536
  const actuals = /* @__PURE__ */ new Map();
12257
12537
  for (const session of summary.sessions) {
@@ -12420,6 +12700,15 @@ var estimateCommand = new Command10("estimate").description("Quick single-featur
12420
12700
  console.log(
12421
12701
  chalk11.bold(` Estimated cost: $${estimate.estimatedCost.toFixed(2)}`)
12422
12702
  );
12703
+ if (estimate.confidence) {
12704
+ const c = estimate.confidence;
12705
+ console.log(
12706
+ chalk11.dim(` Confidence: $${c.low.cost.toFixed(2)} \u2013 $${c.expected.cost.toFixed(2)} \u2013 $${c.high.cost.toFixed(2)} (low / expected / high)`)
12707
+ );
12708
+ console.log(
12709
+ chalk11.dim(` ${c.low.tokens.toLocaleString()} \u2013 ${c.expected.tokens.toLocaleString()} \u2013 ${c.high.tokens.toLocaleString()} tokens`)
12710
+ );
12711
+ }
12423
12712
  console.log(chalk11.dim("\n Overhead breakdown:"));
12424
12713
  console.log(
12425
12714
  ` Context resend: ${estimate.overheadBreakdown.contextResend.toLocaleString()} tokens`
@@ -12846,8 +13135,20 @@ var velocityCommand = new Command12("velocity").description("Show velocity metri
12846
13135
  );
12847
13136
  return;
12848
13137
  }
12849
- const registry = JSON.parse(readFileSync15(registryPath, "utf-8"));
12850
- const summary = JSON.parse(readFileSync15(summaryPath, "utf-8"));
13138
+ let registry;
13139
+ try {
13140
+ registry = JSON.parse(readFileSync15(registryPath, "utf-8"));
13141
+ } catch {
13142
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
13143
+ return process.exit(1);
13144
+ }
13145
+ let summary;
13146
+ try {
13147
+ summary = JSON.parse(readFileSync15(summaryPath, "utf-8"));
13148
+ } catch {
13149
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
13150
+ return process.exit(1);
13151
+ }
12851
13152
  const projectName = options.project ?? registry.project ?? getProjectName();
12852
13153
  const data = loadData();
12853
13154
  const featureDataMap = /* @__PURE__ */ new Map();
@@ -12985,8 +13286,8 @@ Fathom \u2014 Velocity: ${projectName}`));
12985
13286
  // src/commands/init.ts
12986
13287
  import { Command as Command13 } from "commander";
12987
13288
  import {
12988
- writeFileSync as writeFileSync8,
12989
- mkdirSync as mkdirSync9,
13289
+ writeFileSync as writeFileSync9,
13290
+ mkdirSync as mkdirSync10,
12990
13291
  existsSync as existsSync15,
12991
13292
  readFileSync as readFileSync16,
12992
13293
  readdirSync as readdirSync3
@@ -12995,7 +13296,7 @@ import { resolve as resolve15, dirname as dirname6 } from "path";
12995
13296
  import { fileURLToPath as fileURLToPath2 } from "url";
12996
13297
  import { execSync } from "child_process";
12997
13298
  import chalk14 from "chalk";
12998
- import { confirm as confirm3 } from "@inquirer/prompts";
13299
+ import { confirm as confirm5 } from "@inquirer/prompts";
12999
13300
  var initCommand = new Command13("init").description("Initialize Fathom: create global config and scaffold project directory").option("--convex-url <url>", "Convex deployment URL").option("--team <name>", "Team name").option("--admin-key <key>", "Anthropic Admin API key (sk-ant-admin...)").option("--project <name>", "Project name (auto-detected from package.json if omitted)").option("--project-dir <dir>", "Project directory (defaults to current directory)").action(async (options) => {
13000
13301
  const projectDir = options.projectDir ? resolve15(options.projectDir) : process.cwd();
13001
13302
  const projectName = options.project ?? detectProjectName(projectDir);
@@ -13007,7 +13308,7 @@ Fathom \u2014 Init: ${projectName}`));
13007
13308
  ".config",
13008
13309
  "fathom"
13009
13310
  );
13010
- mkdirSync9(configDir, { recursive: true });
13311
+ mkdirSync10(configDir, { recursive: true });
13011
13312
  const configPath = resolve15(configDir, "config.json");
13012
13313
  let existing = {};
13013
13314
  if (existsSync15(configPath)) {
@@ -13023,7 +13324,7 @@ Fathom \u2014 Init: ${projectName}`));
13023
13324
  ...options.adminKey && { anthropicAdminKey: options.adminKey },
13024
13325
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13025
13326
  };
13026
- writeFileSync8(configPath, JSON.stringify(config, null, 2));
13327
+ writeFileSync9(configPath, JSON.stringify(config, null, 2));
13027
13328
  console.log(chalk14.green(` \u2713 Global config: ${configPath}`));
13028
13329
  if (options.convexUrl) {
13029
13330
  console.log(chalk14.dim(` Convex URL: ${options.convexUrl}`));
@@ -13042,7 +13343,7 @@ Fathom \u2014 Init: ${projectName}`));
13042
13343
  if (claudeDetected) {
13043
13344
  console.log("");
13044
13345
  console.log(chalk14.cyan(" Claude Code detected!"));
13045
- const installPlugin = await confirm3({
13346
+ const installPlugin = await confirm5({
13046
13347
  message: "Install Fathom plugin for Claude Code? (adds skills + MCP config)",
13047
13348
  default: true
13048
13349
  });
@@ -13065,8 +13366,8 @@ Fathom \u2014 Init: ${projectName}`));
13065
13366
  "fathom",
13066
13367
  skillDir
13067
13368
  );
13068
- mkdirSync9(destDir, { recursive: true });
13069
- writeFileSync8(
13369
+ mkdirSync10(destDir, { recursive: true });
13370
+ writeFileSync9(
13070
13371
  resolve15(destDir, "SKILL.md"),
13071
13372
  readFileSync16(src, "utf-8")
13072
13373
  );
@@ -13080,8 +13381,8 @@ Fathom \u2014 Init: ${projectName}`));
13080
13381
  );
13081
13382
  if (existsSync15(pluginJsonSrc)) {
13082
13383
  const destDir = resolve15(projectDir, ".claude-plugin");
13083
- mkdirSync9(destDir, { recursive: true });
13084
- writeFileSync8(
13384
+ mkdirSync10(destDir, { recursive: true });
13385
+ writeFileSync9(
13085
13386
  resolve15(destDir, "plugin.json"),
13086
13387
  readFileSync16(pluginJsonSrc, "utf-8")
13087
13388
  );
@@ -13097,63 +13398,96 @@ Fathom \u2014 Init: ${projectName}`));
13097
13398
 
13098
13399
  // src/commands/rename.ts
13099
13400
  import { Command as Command14 } from "commander";
13100
- import { readFileSync as readFileSync17, writeFileSync as writeFileSync9, existsSync as existsSync16 } from "fs";
13401
+ import { readFileSync as readFileSync17, writeFileSync as writeFileSync10, existsSync as existsSync16 } from "fs";
13101
13402
  import { resolve as resolve16 } from "path";
13102
13403
  import chalk15 from "chalk";
13103
- var renameCommand = new Command14("rename").description("Rename the project in all .claude/te/ config files").argument("<name>", "New project name").action((name) => {
13104
- const baseDir = process.cwd();
13105
- const teDir = resolve16(baseDir, ".claude", "te");
13106
- if (!existsSync16(teDir)) {
13107
- console.error(
13108
- chalk15.red("\n Error: .claude/te/ not found. Run `fathom setup` first.\n")
13109
- );
13110
- process.exit(1);
13111
- }
13112
- const jsonFiles = [
13113
- resolve16(teDir, "config.json"),
13114
- resolve16(teDir, "registry.json"),
13115
- resolve16(teDir, "tracking", "summary.json")
13116
- ];
13117
- let oldName = "unnamed";
13118
- let updated = 0;
13119
- for (const filePath of jsonFiles) {
13120
- if (!existsSync16(filePath)) continue;
13121
- const data = JSON.parse(readFileSync17(filePath, "utf-8"));
13122
- if (data.project) {
13123
- oldName = data.project;
13124
- data.project = name;
13125
- writeFileSync9(filePath, JSON.stringify(data, null, 2) + "\n");
13126
- updated++;
13127
- }
13128
- }
13129
- const skillPath = resolve16(baseDir, ".claude", "skills", "fathom", "SKILL.md");
13130
- if (existsSync16(skillPath)) {
13131
- const content3 = readFileSync17(skillPath, "utf-8");
13132
- const newContent = content3.replaceAll(oldName, name);
13133
- if (newContent !== content3) {
13134
- writeFileSync9(skillPath, newContent);
13135
- updated++;
13136
- }
13137
- }
13138
- const hookPath = resolve16(baseDir, ".claude", "hooks", "te-session-sync.sh");
13139
- if (existsSync16(hookPath)) {
13140
- const content3 = readFileSync17(hookPath, "utf-8");
13141
- const newContent = content3.replaceAll(oldName, name);
13142
- if (newContent !== content3) {
13143
- writeFileSync9(hookPath, newContent);
13144
- updated++;
13404
+ import { confirm as confirm6 } from "@inquirer/prompts";
13405
+ var renameCommand = new Command14("rename").description("Rename the project in all .claude/te/ config files").argument("<name>", "New project name").option("-y, --yes", "Skip confirmation prompt").action(async (name, options) => {
13406
+ try {
13407
+ const baseDir = process.cwd();
13408
+ const teDir = resolve16(baseDir, ".claude", "te");
13409
+ if (!existsSync16(teDir)) {
13410
+ console.error(
13411
+ chalk15.red("\n Error: .claude/te/ not found. Run `fathom setup` first.\n")
13412
+ );
13413
+ process.exit(1);
13145
13414
  }
13146
- }
13147
- console.log(
13148
- chalk15.bold(`
13415
+ const jsonFiles = [
13416
+ resolve16(teDir, "config.json"),
13417
+ resolve16(teDir, "registry.json"),
13418
+ resolve16(teDir, "tracking", "summary.json")
13419
+ ];
13420
+ let oldName = "unnamed";
13421
+ const configPath = resolve16(teDir, "config.json");
13422
+ if (existsSync16(configPath)) {
13423
+ try {
13424
+ const configData = JSON.parse(readFileSync17(configPath, "utf-8"));
13425
+ if (configData.project) oldName = configData.project;
13426
+ } catch {
13427
+ }
13428
+ }
13429
+ if (!options.yes) {
13430
+ const proceed = await confirm6({
13431
+ message: `Rename project "${oldName}" to "${name}"?`,
13432
+ default: true
13433
+ });
13434
+ if (!proceed) {
13435
+ console.log(chalk15.dim("\n Aborted."));
13436
+ return;
13437
+ }
13438
+ }
13439
+ let updated = 0;
13440
+ for (const filePath of jsonFiles) {
13441
+ if (!existsSync16(filePath)) continue;
13442
+ let data;
13443
+ try {
13444
+ data = JSON.parse(readFileSync17(filePath, "utf-8"));
13445
+ } catch {
13446
+ console.error(`Failed to parse ${filePath}: file contains invalid JSON`);
13447
+ return process.exit(1);
13448
+ }
13449
+ if (data.project) {
13450
+ oldName = data.project;
13451
+ data.project = name;
13452
+ writeFileSync10(filePath, JSON.stringify(data, null, 2) + "\n");
13453
+ updated++;
13454
+ }
13455
+ }
13456
+ const skillPath = resolve16(baseDir, ".claude", "skills", "fathom", "SKILL.md");
13457
+ if (existsSync16(skillPath)) {
13458
+ const content3 = readFileSync17(skillPath, "utf-8");
13459
+ const newContent = content3.replaceAll(oldName, name);
13460
+ if (newContent !== content3) {
13461
+ writeFileSync10(skillPath, newContent);
13462
+ updated++;
13463
+ }
13464
+ }
13465
+ const hookPath = resolve16(baseDir, ".claude", "hooks", "te-session-sync.sh");
13466
+ if (existsSync16(hookPath)) {
13467
+ const content3 = readFileSync17(hookPath, "utf-8");
13468
+ const newContent = content3.replaceAll(oldName, name);
13469
+ if (newContent !== content3) {
13470
+ writeFileSync10(hookPath, newContent);
13471
+ updated++;
13472
+ }
13473
+ }
13474
+ console.log(
13475
+ chalk15.bold(`
13149
13476
  Renamed "${oldName}" \u2192 "${name}" (${updated} files updated)
13150
13477
  `)
13151
- );
13478
+ );
13479
+ } catch (err) {
13480
+ if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
13481
+ console.log(chalk15.dim("\n Exiting.\n"));
13482
+ return;
13483
+ }
13484
+ throw err;
13485
+ }
13152
13486
  });
13153
13487
 
13154
13488
  // src/commands/research.ts
13155
13489
  import { Command as Command15 } from "commander";
13156
- import { writeFileSync as writeFileSync10, existsSync as existsSync17 } from "fs";
13490
+ import { writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
13157
13491
  import { resolve as resolve17 } from "path";
13158
13492
  import chalk16 from "chalk";
13159
13493
 
@@ -13235,6 +13569,10 @@ function loadLocalPricing(fetchedAt, error) {
13235
13569
  }
13236
13570
 
13237
13571
  // src/fetchers/intelligence-fetcher.ts
13572
+ function maskKey(key) {
13573
+ if (key.length <= 6) return "***";
13574
+ return key.slice(0, 3) + "***" + key.slice(-3);
13575
+ }
13238
13576
  async function getAnthropicClient3(apiKey) {
13239
13577
  const { default: AnthropicSDK } = await import("@anthropic-ai/sdk");
13240
13578
  return new AnthropicSDK({ apiKey: apiKey ?? process.env.ANTHROPIC_API_KEY });
@@ -13307,7 +13645,10 @@ ${recsDesc}`;
13307
13645
  fetchedAt: now
13308
13646
  };
13309
13647
  } catch (err) {
13310
- const errorMsg = err instanceof Error ? err.message : String(err);
13648
+ let errorMsg = err instanceof Error ? err.message : String(err);
13649
+ if (apiKey) {
13650
+ errorMsg = errorMsg.replaceAll(apiKey, maskKey(apiKey));
13651
+ }
13311
13652
  if (options.verbose) {
13312
13653
  console.log(` Intelligence fetch failed: ${errorMsg}`);
13313
13654
  }
@@ -13470,13 +13811,16 @@ var researchCommand = new Command15("research").description("Check and update mo
13470
13811
  console.log(chalk16.yellow(" Warning: could not load local data files"));
13471
13812
  localData = { models: [], recommendations: {} };
13472
13813
  }
13473
- const [pricingResult, intelligenceResult] = await Promise.all([
13474
- fetchPricing({ offline: options.offline, verbose: options.verbose }),
13475
- fetchIntelligence(localData.models, localData.recommendations, {
13476
- offline: options.offline,
13477
- verbose: options.verbose
13478
- })
13479
- ]);
13814
+ const [pricingResult, intelligenceResult] = await withSpinner(
13815
+ "Researching pricing intelligence...",
13816
+ async () => Promise.all([
13817
+ fetchPricing({ offline: options.offline, verbose: options.verbose }),
13818
+ fetchIntelligence(localData.models, localData.recommendations, {
13819
+ offline: options.offline,
13820
+ verbose: options.verbose
13821
+ })
13822
+ ])
13823
+ );
13480
13824
  if (options.verbose) {
13481
13825
  const elapsed = Date.now() - startTime;
13482
13826
  console.log(chalk16.dim(`
@@ -13509,10 +13853,10 @@ var researchCommand = new Command15("research").description("Check and update mo
13509
13853
  const dataDir = findDataDir2();
13510
13854
  const modelsPath = resolve17(dataDir, "models.yaml");
13511
13855
  const agentsPath = resolve17(dataDir, "agents.yaml");
13512
- writeFileSync10(modelsPath, generateModelsYaml(mergeResult.models.merged));
13856
+ writeFileSync11(modelsPath, generateModelsYaml(mergeResult.models.merged));
13513
13857
  console.log(chalk16.green(`
13514
13858
  Updated: ${modelsPath}`));
13515
- writeFileSync10(agentsPath, generateAgentsYaml(mergeResult.recommendations.merged));
13859
+ writeFileSync11(agentsPath, generateAgentsYaml(mergeResult.recommendations.merged));
13516
13860
  console.log(chalk16.green(` Updated: ${agentsPath}`));
13517
13861
  }
13518
13862
  if (options.sync) {
@@ -13636,10 +13980,10 @@ var projectCommand = new Command16("project").description("View or update projec
13636
13980
  console.log(chalk17.dim(" Run `fathom` to set up a project with intent capture.\n"));
13637
13981
  return;
13638
13982
  }
13639
- const { loadIntent } = await import("./dist-XKZLNUDU.js");
13983
+ const { loadIntent } = await import("./dist-VMLJKWUO.js");
13640
13984
  const intent = await loadIntent(cwd);
13641
13985
  if (options.regenerate) {
13642
- const { writeProjections } = await import("./dist-KXBSLOHP.js");
13986
+ const { writeProjections } = await import("./dist-HPXMBCZX.js");
13643
13987
  const files = await writeProjections(cwd, intent);
13644
13988
  console.log(chalk17.green(`
13645
13989
  \u2713 Regenerated ${files.length} projection files from intent`));
@@ -13673,7 +14017,7 @@ var projectCommand = new Command16("project").description("View or update projec
13673
14017
  console.log(` Alert at ${Math.round(intent.budget.alert_threshold * 100)}%`);
13674
14018
  }
13675
14019
  if (intent.guardrails) {
13676
- const { resolveGuardrails } = await import("./dist-XKZLNUDU.js");
14020
+ const { resolveGuardrails } = await import("./dist-VMLJKWUO.js");
13677
14021
  const resolved = resolveGuardrails(intent);
13678
14022
  const total = resolved.security.length + resolved.quality.length + resolved.process.length;
13679
14023
  console.log(chalk17.bold(`
@@ -13713,16 +14057,16 @@ var projectCommand = new Command16("project").description("View or update projec
13713
14057
 
13714
14058
  // src/commands/report.ts
13715
14059
  import { Command as Command17 } from "commander";
13716
- import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync10, existsSync as existsSync19 } from "fs";
14060
+ import { writeFileSync as writeFileSync12, mkdirSync as mkdirSync11, existsSync as existsSync19 } from "fs";
13717
14061
  import { resolve as resolve19, join as join4 } from "path";
13718
14062
  import chalk18 from "chalk";
13719
14063
 
13720
14064
  // ../store/dist/index.js
13721
14065
  import { randomUUID } from "crypto";
13722
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
14066
+ import { mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
13723
14067
  import { dirname as dirname7, join as join3 } from "path";
13724
14068
  import { readFile as readFile2 } from "fs/promises";
13725
- import { join as join22 } from "path";
14069
+ import { isAbsolute, join as join22, normalize } from "path";
13726
14070
  var OverheadBreakdownSchema = external_exports.object({
13727
14071
  contextResend: external_exports.number(),
13728
14072
  toolCalls: external_exports.number(),
@@ -13926,9 +14270,15 @@ async function readJsonFile(path) {
13926
14270
  }
13927
14271
  async function writeJsonFile(path, data) {
13928
14272
  await ensureDir(path);
13929
- const tmp = `${path}.tmp`;
14273
+ const tmp = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
13930
14274
  await writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
13931
- await rename(tmp, path);
14275
+ try {
14276
+ await rename(tmp, path);
14277
+ } catch (err) {
14278
+ await unlink(tmp).catch(() => {
14279
+ });
14280
+ throw err;
14281
+ }
13932
14282
  }
13933
14283
  var FileStore = class {
13934
14284
  baseDir;
@@ -14147,7 +14497,8 @@ var ConvexStore = class {
14147
14497
  this.client = new convex.ConvexHttpClient(this.url);
14148
14498
  this.api = convex.anyApi;
14149
14499
  return true;
14150
- } catch {
14500
+ } catch (err) {
14501
+ console.error(`[FathomStore] Convex client init failed:`, err);
14151
14502
  return false;
14152
14503
  }
14153
14504
  })();
@@ -14162,7 +14513,8 @@ var ConvexStore = class {
14162
14513
  if (!await this.init()) return null;
14163
14514
  try {
14164
14515
  return await this.client.query(fn, args);
14165
- } catch {
14516
+ } catch (err) {
14517
+ console.error(`[FathomStore] query failed:`, err);
14166
14518
  return null;
14167
14519
  }
14168
14520
  }
@@ -14174,7 +14526,8 @@ var ConvexStore = class {
14174
14526
  if (!await this.init()) return null;
14175
14527
  try {
14176
14528
  return await this.client.mutation(fn, args);
14177
- } catch {
14529
+ } catch (err) {
14530
+ console.error(`[FathomStore] mutation failed:`, err);
14178
14531
  return null;
14179
14532
  }
14180
14533
  }
@@ -14409,6 +14762,15 @@ var ConvexStore = class {
14409
14762
  );
14410
14763
  }
14411
14764
  };
14765
+ function validateStorePath(storePath) {
14766
+ if (isAbsolute(storePath)) {
14767
+ throw new Error(`Store path must be relative, got absolute path: "${storePath}"`);
14768
+ }
14769
+ const normalized = normalize(storePath);
14770
+ if (normalized.startsWith("..")) {
14771
+ throw new Error(`Store path must not escape project directory: "${storePath}"`);
14772
+ }
14773
+ }
14412
14774
  async function loadStoreConfig(dir) {
14413
14775
  try {
14414
14776
  const raw = await readFile2(join22(dir, ".fathom", "config.json"), "utf-8");
@@ -14416,6 +14778,7 @@ async function loadStoreConfig(dir) {
14416
14778
  if (config.store && typeof config.store === "object") {
14417
14779
  const store = config.store;
14418
14780
  if (store.type === "file" && typeof store.path === "string") {
14781
+ validateStorePath(store.path);
14419
14782
  return { type: "file", path: store.path };
14420
14783
  }
14421
14784
  if (store.type === "convex" && typeof store.url === "string") {
@@ -14433,7 +14796,8 @@ function createStore(config) {
14433
14796
  }
14434
14797
  switch (config.type) {
14435
14798
  case "file":
14436
- return new FileStore(config.path);
14799
+ validateStorePath(config.path);
14800
+ return new FileStore(join22(process.cwd(), config.path));
14437
14801
  case "convex":
14438
14802
  return new ConvexStore({ url: config.url });
14439
14803
  default:
@@ -15347,9 +15711,9 @@ Fathom \u2014 Project Report: ${projectName}`));
15347
15711
  const outputPath = options.output ? resolve19(options.output) : defaultPath;
15348
15712
  const dir = resolve19(outputPath, "..");
15349
15713
  if (!existsSync19(dir)) {
15350
- mkdirSync10(dir, { recursive: true });
15714
+ mkdirSync11(dir, { recursive: true });
15351
15715
  }
15352
- writeFileSync11(outputPath, report, "utf-8");
15716
+ writeFileSync12(outputPath, report, "utf-8");
15353
15717
  console.log(
15354
15718
  chalk18.green(`
15355
15719
  \u2713 Report generated: ${outputPath}`)
@@ -15365,17 +15729,29 @@ Fathom \u2014 Project Report: ${projectName}`));
15365
15729
 
15366
15730
  // src/commands/contribute.ts
15367
15731
  import { Command as Command18 } from "commander";
15368
- import { readFileSync as readFileSync18, writeFileSync as writeFileSync12, existsSync as existsSync20, mkdirSync as mkdirSync11 } from "fs";
15732
+ import { readFileSync as readFileSync18, writeFileSync as writeFileSync13, existsSync as existsSync20, mkdirSync as mkdirSync12 } from "fs";
15369
15733
  import { resolve as resolve20 } from "path";
15370
15734
  import chalk19 from "chalk";
15371
- import { confirm as confirm4 } from "@inquirer/prompts";
15735
+ import { confirm as confirm7 } from "@inquirer/prompts";
15372
15736
  function loadCalibratedProfiles() {
15373
15737
  const profiles = [];
15374
15738
  const registryPath = resolve20(process.cwd(), ".claude", "te", "registry.json");
15375
15739
  const summaryPath = resolve20(process.cwd(), ".claude", "te", "tracking", "summary.json");
15376
15740
  if (existsSync20(registryPath) && existsSync20(summaryPath)) {
15377
- const registry = JSON.parse(readFileSync18(registryPath, "utf-8"));
15378
- const summary = JSON.parse(readFileSync18(summaryPath, "utf-8"));
15741
+ let registry;
15742
+ try {
15743
+ registry = JSON.parse(readFileSync18(registryPath, "utf-8"));
15744
+ } catch {
15745
+ console.error(`Failed to parse ${registryPath}: file contains invalid JSON`);
15746
+ return process.exit(1);
15747
+ }
15748
+ let summary;
15749
+ try {
15750
+ summary = JSON.parse(readFileSync18(summaryPath, "utf-8"));
15751
+ } catch {
15752
+ console.error(`Failed to parse ${summaryPath}: file contains invalid JSON`);
15753
+ return process.exit(1);
15754
+ }
15379
15755
  const actuals = /* @__PURE__ */ new Map();
15380
15756
  for (const session of summary.sessions ?? []) {
15381
15757
  if (!session.featureId || session.status === "untagged") continue;
@@ -15452,7 +15828,7 @@ var contributeCommand = new Command18("contribute").description("Share anonymize
15452
15828
  }
15453
15829
  if (!options.yes) {
15454
15830
  try {
15455
- const confirmed = await confirm4({
15831
+ const confirmed = await confirm7({
15456
15832
  message: "Share this data?",
15457
15833
  default: false
15458
15834
  });
@@ -15466,11 +15842,11 @@ var contributeCommand = new Command18("contribute").description("Share anonymize
15466
15842
  }
15467
15843
  }
15468
15844
  const contributionsDir = resolve20(process.cwd(), ".fathom", "contributions");
15469
- mkdirSync11(contributionsDir, { recursive: true });
15845
+ mkdirSync12(contributionsDir, { recursive: true });
15470
15846
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
15471
15847
  const outputPath = resolve20(contributionsDir, `${today}.json`);
15472
15848
  const json = exportContribution(dataPoints);
15473
- writeFileSync12(outputPath, json, "utf-8");
15849
+ writeFileSync13(outputPath, json, "utf-8");
15474
15850
  console.log(chalk19.green(`
15475
15851
  \u2713 Contribution saved to .fathom/contributions/${today}.json`));
15476
15852
  console.log(chalk19.green(" \u2713 Thank you! This helps make Fathom better for everyone."));
@@ -15479,7 +15855,7 @@ var contributeCommand = new Command18("contribute").description("Share anonymize
15479
15855
 
15480
15856
  // src/commands/feedback.ts
15481
15857
  import { Command as Command19 } from "commander";
15482
- import { writeFileSync as writeFileSync13, mkdirSync as mkdirSync12, readFileSync as readFileSync19, existsSync as existsSync21 } from "fs";
15858
+ import { writeFileSync as writeFileSync14, mkdirSync as mkdirSync13, readFileSync as readFileSync19, existsSync as existsSync21 } from "fs";
15483
15859
  import { resolve as resolve21, join as join5 } from "path";
15484
15860
  import chalk20 from "chalk";
15485
15861
  function getIntentSummary() {
@@ -15507,7 +15883,7 @@ function getSystemInfo() {
15507
15883
  }
15508
15884
  function saveFeedback(type, description) {
15509
15885
  const feedbackDir = resolve21(process.cwd(), ".fathom", "feedback");
15510
- mkdirSync12(feedbackDir, { recursive: true });
15886
+ mkdirSync13(feedbackDir, { recursive: true });
15511
15887
  const sys = getSystemInfo();
15512
15888
  const intent = getIntentSummary();
15513
15889
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
@@ -15544,7 +15920,7 @@ function saveFeedback(type, description) {
15544
15920
  "Review this file, add any additional details, then email to feedback@fathom.dev"
15545
15921
  );
15546
15922
  lines.push("");
15547
- writeFileSync13(filepath, lines.join("\n"));
15923
+ writeFileSync14(filepath, lines.join("\n"));
15548
15924
  return filepath;
15549
15925
  }
15550
15926
  var feedbackCommand = new Command19("feedback").description("How to report bugs and request features").option("--bug <description>", "Save a bug report to .fathom/feedback/").option(
@@ -15605,9 +15981,10 @@ here's how to reach us:
15605
15981
  // src/commands/sync.ts
15606
15982
  import { Command as Command20 } from "commander";
15607
15983
  import chalk21 from "chalk";
15984
+ import { confirm as confirm8 } from "@inquirer/prompts";
15608
15985
 
15609
15986
  // ../sync/dist/index.js
15610
- import { readFileSync as readFileSync20, writeFileSync as writeFileSync14, mkdirSync as mkdirSync13, existsSync as existsSync23 } from "fs";
15987
+ import { readFileSync as readFileSync20, writeFileSync as writeFileSync15, mkdirSync as mkdirSync14, existsSync as existsSync23 } from "fs";
15611
15988
  import { resolve as resolve22, dirname as dirname8 } from "path";
15612
15989
  var SyncStatusSchema = external_exports.enum([
15613
15990
  "backlog",
@@ -16300,8 +16677,8 @@ function loadLedger(ledgerPath) {
16300
16677
  return { provider: "", projectKey: "", items: [], lastSync: 0 };
16301
16678
  }
16302
16679
  function saveLedger(ledgerPath, ledger) {
16303
- mkdirSync13(dirname8(ledgerPath), { recursive: true });
16304
- writeFileSync14(ledgerPath, JSON.stringify(ledger, null, 2) + "\n");
16680
+ mkdirSync14(dirname8(ledgerPath), { recursive: true });
16681
+ writeFileSync15(ledgerPath, JSON.stringify(ledger, null, 2) + "\n");
16305
16682
  }
16306
16683
  function createSyncManager(config, options) {
16307
16684
  const apiKey = resolveApiKey(config);
@@ -16412,7 +16789,7 @@ function formatStatus2(status) {
16412
16789
  };
16413
16790
  return (colors[status] ?? chalk21.white)(status);
16414
16791
  }
16415
- var syncCommand = new Command20("sync").description("Sync spec slices to external PM tools (Linear, GitHub, Notion, Jira)").requiredOption("--provider <provider>", "PM tool provider (linear, github, notion, jira)").requiredOption("--project-key <key>", "Project key (Linear team key, GitHub owner/repo, Notion database ID, Jira host/project)").option("--pull", "Only pull status updates, don't push new slices").option("--dry-run", "Show what would be synced without making changes").action(async (options) => {
16792
+ var syncCommand = new Command20("sync").description("Sync spec slices to external PM tools (Linear, GitHub, Notion, Jira)").requiredOption("--provider <provider>", "PM tool provider (linear, github, notion, jira)").requiredOption("--project-key <key>", "Project key (Linear team key, GitHub owner/repo, Notion database ID, Jira host/project)").option("--pull", "Only pull status updates, don't push new slices").option("--dry-run", "Show what would be synced without making changes").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
16416
16793
  try {
16417
16794
  const config = loadSyncConfig(options);
16418
16795
  if (!config.projectKey) {
@@ -16435,10 +16812,23 @@ Fathom \u2014 Sync to ${config.provider.charAt(0).toUpperCase() + config.provide
16435
16812
  console.log(chalk21.dim(` - ${s.sliceId}: ${s.title}`));
16436
16813
  }
16437
16814
  } else {
16815
+ if (!options.yes) {
16816
+ const proceed = await confirm8({
16817
+ message: `Sync ${slices.length} slices to ${config.provider}?`,
16818
+ default: true
16819
+ });
16820
+ if (!proceed) {
16821
+ console.log(chalk21.dim("\n Aborted."));
16822
+ return;
16823
+ }
16824
+ }
16438
16825
  console.log(chalk21.dim(`
16439
16826
  Syncing ${slices.length} pending slice(s) to ${config.provider} project ${config.projectKey}...
16440
16827
  `));
16441
- const results = await manager.syncSlices(slices);
16828
+ const results = await withSpinner(
16829
+ `Syncing slices to ${config.provider}...`,
16830
+ async () => manager.syncSlices(slices)
16831
+ );
16442
16832
  if (results.length === 0) {
16443
16833
  console.log(chalk21.dim(" All slices already synced."));
16444
16834
  } else {
@@ -16450,7 +16840,10 @@ Fathom \u2014 Sync to ${config.provider.charAt(0).toUpperCase() + config.provide
16450
16840
  }
16451
16841
  if (!options.dryRun) {
16452
16842
  console.log(chalk21.dim("\n Pulling status updates..."));
16453
- const updates = await manager.pullStatus();
16843
+ const updates = await withSpinner(
16844
+ "Pulling status updates...",
16845
+ async () => manager.pullStatus()
16846
+ );
16454
16847
  if (updates.length === 0) {
16455
16848
  console.log(chalk21.dim(" No status changes."));
16456
16849
  } else {
@@ -16481,9 +16874,9 @@ Fathom \u2014 Sync to ${config.provider.charAt(0).toUpperCase() + config.provide
16481
16874
  // src/commands/install-plugin.ts
16482
16875
  import { Command as Command21 } from "commander";
16483
16876
  import {
16484
- writeFileSync as writeFileSync15,
16877
+ writeFileSync as writeFileSync16,
16485
16878
  readFileSync as readFileSync23,
16486
- mkdirSync as mkdirSync14,
16879
+ mkdirSync as mkdirSync15,
16487
16880
  existsSync as existsSync25,
16488
16881
  readdirSync as readdirSync4
16489
16882
  } from "fs";
@@ -16523,8 +16916,8 @@ var installPluginCommand = new Command21("install-plugin").description(
16523
16916
  "fathom",
16524
16917
  skillDir
16525
16918
  );
16526
- mkdirSync14(destDir, { recursive: true });
16527
- writeFileSync15(resolve24(destDir, "SKILL.md"), readFileSync23(src, "utf-8"));
16919
+ mkdirSync15(destDir, { recursive: true });
16920
+ writeFileSync16(resolve24(destDir, "SKILL.md"), readFileSync23(src, "utf-8"));
16528
16921
  console.log(chalk22.green(` \u2713 .claude/skills/fathom/${skillDir}/SKILL.md`));
16529
16922
  }
16530
16923
  }
@@ -16535,8 +16928,8 @@ var installPluginCommand = new Command21("install-plugin").description(
16535
16928
  );
16536
16929
  if (existsSync25(pluginJsonSrc)) {
16537
16930
  const destDir = resolve24(projectDir, ".claude-plugin");
16538
- mkdirSync14(destDir, { recursive: true });
16539
- writeFileSync15(
16931
+ mkdirSync15(destDir, { recursive: true });
16932
+ writeFileSync16(
16540
16933
  resolve24(destDir, "plugin.json"),
16541
16934
  readFileSync23(pluginJsonSrc, "utf-8")
16542
16935
  );
@@ -16556,7 +16949,7 @@ var installPluginCommand = new Command21("install-plugin").description(
16556
16949
  command: "npx",
16557
16950
  args: ["-y", "fathom-token-mcp@latest", "--project-dir", "."]
16558
16951
  };
16559
- writeFileSync15(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
16952
+ writeFileSync16(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
16560
16953
  console.log(chalk22.green(" \u2713 .mcp.json (MCP auto-discovery)"));
16561
16954
  console.log(chalk22.bold("\n\u2713 Fathom plugin installed"));
16562
16955
  console.log(
@@ -16564,8 +16957,261 @@ var installPluginCommand = new Command21("install-plugin").description(
16564
16957
  );
16565
16958
  });
16566
16959
 
16960
+ // src/commands/completions.ts
16961
+ import { Command as Command22 } from "commander";
16962
+ var COMMANDS = [
16963
+ "go",
16964
+ "intake",
16965
+ "analyze",
16966
+ "estimate",
16967
+ "setup",
16968
+ "init",
16969
+ "track",
16970
+ "reconcile",
16971
+ "calibrate",
16972
+ "velocity",
16973
+ "report",
16974
+ "project",
16975
+ "contribute",
16976
+ "feedback",
16977
+ "sync",
16978
+ "install-plugin",
16979
+ "status",
16980
+ "pricing",
16981
+ "validate",
16982
+ "rename",
16983
+ "research",
16984
+ "doctor",
16985
+ "completions"
16986
+ ];
16987
+ var GLOBAL_FLAGS = ["--help", "--version"];
16988
+ function generateBashCompletion() {
16989
+ const words = [...COMMANDS, ...GLOBAL_FLAGS].join(" ");
16990
+ return `#!/bin/bash
16991
+ # bash completion for fathom
16992
+
16993
+ _fathom() {
16994
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
16995
+ local commands="${words}"
16996
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
16997
+ return 0
16998
+ }
16999
+
17000
+ complete -F _fathom fathom
17001
+ `;
17002
+ }
17003
+ function generateZshCompletion() {
17004
+ const words = [...COMMANDS, ...GLOBAL_FLAGS].join(" ");
17005
+ return `#compdef fathom
17006
+ # zsh completion for fathom
17007
+
17008
+ _fathom() {
17009
+ local -a commands
17010
+ commands=(${words})
17011
+ compadd -a commands
17012
+ }
17013
+
17014
+ _fathom "$@"
17015
+ `;
17016
+ }
17017
+ var completionsCommand = new Command22("completions").description("Generate shell completion scripts").argument("<shell>", "Shell type: bash or zsh").action((shell) => {
17018
+ switch (shell) {
17019
+ case "bash":
17020
+ process.stdout.write(generateBashCompletion());
17021
+ break;
17022
+ case "zsh":
17023
+ process.stdout.write(generateZshCompletion());
17024
+ break;
17025
+ default:
17026
+ console.error(
17027
+ "Unsupported shell. Use: fathom completions bash|zsh"
17028
+ );
17029
+ process.exitCode = 1;
17030
+ }
17031
+ });
17032
+
17033
+ // src/commands/doctor.ts
17034
+ import { Command as Command23 } from "commander";
17035
+ import { existsSync as existsSync26, readFileSync as readFileSync24 } from "fs";
17036
+ import { resolve as resolve25 } from "path";
17037
+ import chalk23 from "chalk";
17038
+ async function runChecks(cwd) {
17039
+ const results = [];
17040
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
17041
+ results.push({
17042
+ name: "Node version >= 20",
17043
+ ok: nodeMajor >= 20,
17044
+ message: nodeMajor >= 20 ? `Node.js v${process.versions.node}` : "Upgrade to Node.js 20 or later"
17045
+ });
17046
+ const fathomDir = resolve25(cwd, ".fathom");
17047
+ const hasFathomDir = existsSync26(fathomDir);
17048
+ results.push({
17049
+ name: ".fathom/ directory",
17050
+ ok: hasFathomDir,
17051
+ message: hasFathomDir ? ".fathom/ found" : "Run `fathom init` to create project config"
17052
+ });
17053
+ const configPath = resolve25(fathomDir, "config.json");
17054
+ let configOk = false;
17055
+ let configMsg = "Run `fathom init` to create project config";
17056
+ if (hasFathomDir && existsSync26(configPath)) {
17057
+ try {
17058
+ JSON.parse(readFileSync24(configPath, "utf-8"));
17059
+ configOk = true;
17060
+ configMsg = "config.json valid";
17061
+ } catch {
17062
+ configMsg = "Delete .fathom/config.json and run `fathom init`";
17063
+ }
17064
+ } else if (hasFathomDir) {
17065
+ configMsg = "Run `fathom init` to create config.json";
17066
+ }
17067
+ results.push({
17068
+ name: "config.json valid",
17069
+ ok: configOk,
17070
+ message: configMsg
17071
+ });
17072
+ const intentPath = resolve25(fathomDir, "intent.yaml");
17073
+ let intentOk = false;
17074
+ let intentMsg = "Run `fathom init` to create intent.yaml";
17075
+ if (hasFathomDir && existsSync26(intentPath)) {
17076
+ try {
17077
+ jsYaml.load(readFileSync24(intentPath, "utf-8"), { schema: jsYaml.JSON_SCHEMA });
17078
+ intentOk = true;
17079
+ intentMsg = "intent.yaml parseable";
17080
+ } catch {
17081
+ intentMsg = "Check intent.yaml syntax \u2014 run `fathom validate`";
17082
+ }
17083
+ }
17084
+ results.push({
17085
+ name: "intent.yaml parseable",
17086
+ ok: intentOk,
17087
+ message: intentMsg
17088
+ });
17089
+ const legacyDir = resolve25(cwd, ".claude", "te");
17090
+ const legacyRegistry = resolve25(legacyDir, "registry.json");
17091
+ const legacyTracking = resolve25(legacyDir, "tracking", "summary.json");
17092
+ const hasLegacyRegistry = existsSync26(legacyRegistry);
17093
+ const hasLegacyTracking = existsSync26(legacyTracking);
17094
+ if (existsSync26(legacyDir)) {
17095
+ results.push({
17096
+ name: "Legacy .claude/te/ data",
17097
+ ok: hasLegacyRegistry && hasLegacyTracking,
17098
+ message: hasLegacyRegistry && hasLegacyTracking ? "Legacy registry and tracking data found" : `Missing ${!hasLegacyRegistry ? "registry.json" : ""}${!hasLegacyRegistry && !hasLegacyTracking ? " and " : ""}${!hasLegacyTracking ? "tracking/summary.json" : ""} \u2014 run \`fathom analyze\` and \`fathom track\` to populate`
17099
+ });
17100
+ }
17101
+ let dataOk = false;
17102
+ let dataMsg = "Reinstall fathom \u2014 `npm i -g fathom`";
17103
+ try {
17104
+ loadData();
17105
+ dataOk = true;
17106
+ dataMsg = "Core data files loaded";
17107
+ } catch {
17108
+ }
17109
+ results.push({
17110
+ name: "Data files loadable",
17111
+ ok: dataOk,
17112
+ message: dataMsg
17113
+ });
17114
+ return results;
17115
+ }
17116
+ var doctorCommand = new Command23("doctor").description("Check project health and configuration").action(async () => {
17117
+ console.log(chalk23.bold("\nFathom \u2014 Doctor"));
17118
+ console.log(chalk23.dim("\u2500".repeat(50)));
17119
+ const results = await runChecks(process.cwd());
17120
+ let passed = 0;
17121
+ for (const r of results) {
17122
+ if (r.ok) {
17123
+ console.log(chalk23.green(` \u2713 ${r.name}`));
17124
+ passed++;
17125
+ } else {
17126
+ console.log(chalk23.red(` \u2717 ${r.name} \u2014 ${r.message}`));
17127
+ }
17128
+ }
17129
+ console.log(
17130
+ chalk23.dim(`
17131
+ ${passed}/${results.length} checks passed
17132
+ `)
17133
+ );
17134
+ if (passed < results.length) {
17135
+ process.exit(1);
17136
+ }
17137
+ });
17138
+
17139
+ // src/index.ts
17140
+ import chalk24 from "chalk";
17141
+
17142
+ // src/ux/update-checker.ts
17143
+ import { readFileSync as readFileSync25, writeFileSync as writeFileSync17, mkdirSync as mkdirSync16, existsSync as existsSync27 } from "fs";
17144
+ import { join as join6 } from "path";
17145
+ import { homedir as homedir3 } from "os";
17146
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
17147
+ function getCachePath() {
17148
+ return join6(homedir3(), ".fathom", "update-check.json");
17149
+ }
17150
+ function readCache() {
17151
+ try {
17152
+ const cachePath = getCachePath();
17153
+ if (!existsSync27(cachePath)) return null;
17154
+ const raw = readFileSync25(cachePath, "utf-8");
17155
+ return JSON.parse(raw);
17156
+ } catch {
17157
+ return null;
17158
+ }
17159
+ }
17160
+ function writeCache(latest) {
17161
+ try {
17162
+ const cachePath = getCachePath();
17163
+ const dir = join6(homedir3(), ".fathom");
17164
+ if (!existsSync27(dir)) {
17165
+ mkdirSync16(dir, { recursive: true });
17166
+ }
17167
+ writeFileSync17(
17168
+ cachePath,
17169
+ JSON.stringify({ latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() })
17170
+ );
17171
+ } catch {
17172
+ }
17173
+ }
17174
+ function isCacheFresh(cache) {
17175
+ const checkedAt = new Date(cache.checkedAt).getTime();
17176
+ return Date.now() - checkedAt < CACHE_TTL_MS;
17177
+ }
17178
+ async function checkForUpdate() {
17179
+ try {
17180
+ const current = VERSION;
17181
+ const cache = readCache();
17182
+ if (cache && isCacheFresh(cache)) {
17183
+ return {
17184
+ current,
17185
+ latest: cache.latest,
17186
+ updateAvailable: cache.latest !== current
17187
+ };
17188
+ }
17189
+ const controller = new AbortController();
17190
+ const timeout = setTimeout(() => controller.abort(), 3e3);
17191
+ try {
17192
+ const response = await fetch(
17193
+ "https://registry.npmjs.org/fathom-cli/latest",
17194
+ { signal: controller.signal }
17195
+ );
17196
+ if (!response.ok) return null;
17197
+ const data = await response.json();
17198
+ const latest = data.version;
17199
+ writeCache(latest);
17200
+ return {
17201
+ current,
17202
+ latest,
17203
+ updateAvailable: latest !== current
17204
+ };
17205
+ } finally {
17206
+ clearTimeout(timeout);
17207
+ }
17208
+ } catch {
17209
+ return null;
17210
+ }
17211
+ }
17212
+
16567
17213
  // src/index.ts
16568
- var program = new Command22();
17214
+ var program = new Command24();
16569
17215
  program.name("fathom").description("Workflow intelligence platform for AI-augmented development").version(VERSION);
16570
17216
  program.addCommand(goCommand, { isDefault: true });
16571
17217
  program.addCommand(intakeCommand);
@@ -16588,5 +17234,17 @@ program.addCommand(pricingCommand);
16588
17234
  program.addCommand(validateCommand);
16589
17235
  program.addCommand(renameCommand);
16590
17236
  program.addCommand(researchCommand);
17237
+ program.addCommand(completionsCommand);
17238
+ program.addCommand(doctorCommand);
16591
17239
  program.parse();
17240
+ checkForUpdate().then((result) => {
17241
+ if (result?.updateAvailable) {
17242
+ console.error(
17243
+ chalk24.yellow(`
17244
+ Update available: ${result.current} \u2192 ${result.latest}`)
17245
+ );
17246
+ console.error(chalk24.yellow(`Run: npm i -g fathom-cli`));
17247
+ }
17248
+ }).catch(() => {
17249
+ });
16592
17250
  //# sourceMappingURL=index.js.map