@agent-finops/core 0.8.1 → 0.9.1

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.
Files changed (51) hide show
  1. package/README.md +5 -3
  2. package/dist/actionPlanner.d.ts +140 -0
  3. package/dist/actionPlanner.js +938 -0
  4. package/dist/actionVerification.d.ts +1240 -0
  5. package/dist/actionVerification.js +1028 -0
  6. package/dist/activitySnapshot.d.ts +142 -50
  7. package/dist/activitySnapshot.js +145 -6
  8. package/dist/activitySnapshotCache.d.ts +8 -1
  9. package/dist/activitySnapshotCache.js +103 -7
  10. package/dist/agentDraftToken.d.ts +80 -0
  11. package/dist/agentDraftToken.js +188 -0
  12. package/dist/agentEconomicsReceipt.d.ts +74 -74
  13. package/dist/agentLoopContract.d.ts +27 -0
  14. package/dist/agentLoopContract.js +36 -0
  15. package/dist/glance.d.ts +27 -1
  16. package/dist/glance.js +151 -12
  17. package/dist/guidedAnswer.d.ts +51 -0
  18. package/dist/guidedAnswer.js +352 -0
  19. package/dist/index.d.ts +14 -2
  20. package/dist/index.js +13 -1
  21. package/dist/localAgentFormats/gemini.js +2 -2
  22. package/dist/localAgentFormats/registry.js +6 -2
  23. package/dist/localAgentFormats/runtimeRegistry.js +5 -2
  24. package/dist/localAgentFormats/types.d.ts +2 -1
  25. package/dist/localAgentLogs.d.ts +362 -3
  26. package/dist/localAgentLogs.js +1964 -165
  27. package/dist/modelPricing.d.ts +1 -1
  28. package/dist/modelPricing.js +1 -1
  29. package/dist/projectEconomics.d.ts +617 -0
  30. package/dist/projectEconomics.js +620 -0
  31. package/dist/projectEconomicsBuilder.d.ts +89 -0
  32. package/dist/projectEconomicsBuilder.js +473 -0
  33. package/dist/projectIndexStore.d.ts +545 -0
  34. package/dist/projectIndexStore.js +606 -0
  35. package/dist/providerConnectors.d.ts +161 -1
  36. package/dist/providerConnectors.js +406 -11
  37. package/dist/qualitativeIndexCache.d.ts +494 -0
  38. package/dist/qualitativeIndexCache.js +930 -0
  39. package/dist/resultCard.d.ts +350 -0
  40. package/dist/resultCard.js +604 -0
  41. package/dist/runtimeCommands.d.ts +36 -0
  42. package/dist/runtimeCommands.js +50 -0
  43. package/dist/scanGuard.d.ts +3 -1
  44. package/dist/scanGuard.js +164 -4
  45. package/dist/schema.d.ts +33 -31
  46. package/dist/schema.js +9 -1
  47. package/dist/sessionVitals.d.ts +145 -0
  48. package/dist/sessionVitals.js +521 -0
  49. package/dist/toolInvocations.d.ts +40 -1
  50. package/dist/toolInvocations.js +101 -20
  51. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { aggregateCalls, dedupeCumulativeSessionCalls } from "./localAgentLogs.js";
3
3
  import { localAgentFormatDescriptors } from "./localAgentFormats/registry.js";
4
4
  import { canPriceTokenUsageAtScope, estimateTokenCostUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
5
+ import { subscriptionPlans } from "./planMath.js";
5
6
  import { isBundledSampleUsage } from "./schema.js";
6
7
  import { sourceValidationCoverageValues } from "./sourceStatus.js";
7
8
  const DAY_MS = 24 * 60 * 60 * 1_000;
@@ -163,6 +164,8 @@ export const activitySnapshotSubscriptionAgentSchema = z.object({
163
164
  agent: agentSchema,
164
165
  billing: z.literal("subscription"),
165
166
  planId: planIdSchema,
167
+ /** C-lane §2.1: detected-plan list price; null when the plan is unpriced. */
168
+ committedUsdPerMonth: usdSchema.nullable(),
166
169
  apiEquivalent: activitySnapshotApiEquivalentWindowsSchema,
167
170
  limits: z.array(activitySnapshotLimitSchema).max(2),
168
171
  pressure: z.enum(["extra_usage_credits_exhausted"]).nullable()
@@ -215,6 +218,41 @@ const activitySnapshotUnresolvedSchema = z.object({
215
218
  });
216
219
  }
217
220
  });
221
+ /**
222
+ * C-lane §2.1: a provider-billed subscription with no local agent (cursor
223
+ * today). The writer includes a billed30d amount ONLY when its financial
224
+ * evidence is "verified"; the renderer independently drops anything else
225
+ * (double lock: estimated/unverified provider dollars can never reach a
226
+ * statusline segment).
227
+ */
228
+ export const activitySnapshotProviderSubscriptionSchema = z.object({
229
+ provider: z.enum(activitySnapshotProviderValues),
230
+ billing: z.literal("subscription"),
231
+ planLabel: z.string().min(1).max(64).nullable(),
232
+ committedUsdPerMonth: usdSchema.nullable(),
233
+ billed30d: activitySnapshotBilledWindowSchema
234
+ }).strict();
235
+ /** C-lane §2.1: the committed $/mo total across every detected subscription. */
236
+ export const activitySnapshotCommittedTotalSchema = z.object({
237
+ amountUsd: usdSchema.nullable(),
238
+ pricedSubs: countSchema,
239
+ totalSubs: countSchema
240
+ }).strict().superRefine((total, context) => {
241
+ if (total.pricedSubs > total.totalSubs) {
242
+ context.addIssue({
243
+ code: "custom",
244
+ path: ["pricedSubs"],
245
+ message: "Priced subscriptions cannot exceed total subscriptions."
246
+ });
247
+ }
248
+ if ((total.amountUsd === null) !== (total.pricedSubs === 0)) {
249
+ context.addIssue({
250
+ code: "custom",
251
+ path: ["amountUsd"],
252
+ message: "A committed total exists exactly when at least one subscription is priced."
253
+ });
254
+ }
255
+ });
218
256
  export const activitySnapshotOverageSchema = z.object({
219
257
  amountUsd: z.number().finite().positive(),
220
258
  currency: z.literal("USD"),
@@ -330,7 +368,10 @@ export const activitySnapshotCoverageSchema = z.object({
330
368
  });
331
369
  export const activitySnapshotSchema = z.object({
332
370
  kind: z.literal("aibill.activity_snapshot"),
333
- schemaVersion: z.literal(1),
371
+ // v2 (C-lane §2.1): adds per-agent committedUsdPerMonth, provider-billed
372
+ // subscriptions, and the committed total. The writer dual-writes a v1
373
+ // payload for already-installed v1 runners during the deprecation window.
374
+ schemaVersion: z.literal(2),
334
375
  currency: z.literal("USD"),
335
376
  asOf: isoTimestampSchema,
336
377
  generatedAt: isoTimestampSchema,
@@ -347,6 +388,8 @@ export const activitySnapshotSchema = z.object({
347
388
  subscription: activitySnapshotSubscriptionSchema.nullable(),
348
389
  metered: activitySnapshotMeteredSchema.nullable(),
349
390
  unresolved: activitySnapshotUnresolvedSchema.nullable(),
391
+ providers: z.array(activitySnapshotProviderSubscriptionSchema).max(5).nullable(),
392
+ committedTotal: activitySnapshotCommittedTotalSchema,
350
393
  overage: activitySnapshotOverageSchema.nullable(),
351
394
  coverage: activitySnapshotCoverageSchema,
352
395
  networkUploaded: z.literal(false)
@@ -372,6 +415,20 @@ export const activitySnapshotSchema = z.object({
372
415
  (snapshot.subscription || snapshot.metered || snapshot.unresolved || snapshot.overage)) {
373
416
  invalid("Empty and error snapshots cannot carry financial cohorts.", ["mode"]);
374
417
  }
418
+ if ((snapshot.mode === "empty" || snapshot.mode === "error") &&
419
+ (snapshot.providers || snapshot.committedTotal.amountUsd !== null ||
420
+ snapshot.committedTotal.totalSubs !== 0)) {
421
+ invalid("Empty and error snapshots cannot carry subscription pricing.", ["committedTotal"]);
422
+ }
423
+ if (snapshot.providers &&
424
+ new Set(snapshot.providers.map((provider) => provider.provider)).size !== snapshot.providers.length) {
425
+ invalid("A provider subscription may appear only once.", ["providers"]);
426
+ }
427
+ const expectedTotalSubs = (snapshot.subscription?.agents.length ?? 0) +
428
+ (snapshot.providers?.length ?? 0);
429
+ if (snapshot.committedTotal.totalSubs !== expectedTotalSubs) {
430
+ invalid("The committed total must count every subscription row exactly once.", ["committedTotal"]);
431
+ }
375
432
  if (snapshot.mode === "error" && snapshot.refresh.status !== "error") {
376
433
  invalid("Error mode requires an error refresh state.", ["refresh"]);
377
434
  }
@@ -559,9 +616,32 @@ export function buildActivitySnapshot(input) {
559
616
  } : null;
560
617
  const overage = buildOverage(meteredBilledRecords, overageIds, asOfMs);
561
618
  const coverage = buildCoverage(classified.map((entry) => entry.record), scans, providers, input.pricingAsOf ?? PRICING_TABLE_AS_OF, deduplicated.conflictingIds);
619
+ const finalSubscription = mode === "metered" || mode === "unresolved" || mode === "empty"
620
+ ? null
621
+ : subscription;
622
+ // Writer-side lock (C-lane §2.1): a provider-billed amount survives only
623
+ // when its supplied window is verified; anything else degrades to missing.
624
+ const providerSubscriptionRows = mode === "empty"
625
+ ? []
626
+ : (input.providerSubscriptions ?? []).map((row) => ({
627
+ provider: row.provider,
628
+ billing: "subscription",
629
+ planLabel: row.planLabel,
630
+ committedUsdPerMonth: row.committedUsdPerMonth,
631
+ billed30d: row.billed30d &&
632
+ row.billed30d.financialEvidence === "verified" &&
633
+ row.billed30d.amountUsd !== null
634
+ ? row.billed30d
635
+ : missingBilledWindow()
636
+ }));
637
+ const committedRows = [
638
+ ...(finalSubscription?.agents ?? []).map((agent) => agent.committedUsdPerMonth),
639
+ ...providerSubscriptionRows.map((row) => row.committedUsdPerMonth)
640
+ ];
641
+ const pricedRows = committedRows.filter((amount) => amount !== null);
562
642
  return activitySnapshotSchema.parse({
563
643
  kind: "aibill.activity_snapshot",
564
- schemaVersion: 1,
644
+ schemaVersion: 2,
565
645
  currency: "USD",
566
646
  asOf: new Date(asOfMs).toISOString(),
567
647
  generatedAt,
@@ -569,24 +649,80 @@ export function buildActivitySnapshot(input) {
569
649
  lastSuccessAt: generatedAt,
570
650
  refresh: { status: "ok" },
571
651
  mode,
572
- subscription: mode === "metered" || mode === "unresolved" || mode === "empty"
573
- ? null
574
- : subscription,
652
+ subscription: finalSubscription,
575
653
  metered: mode === "subscription" || mode === "unresolved" || mode === "empty"
576
654
  ? null
577
655
  : metered,
578
656
  unresolved,
657
+ providers: providerSubscriptionRows.length > 0 ? providerSubscriptionRows : null,
658
+ committedTotal: {
659
+ amountUsd: pricedRows.length > 0
660
+ ? roundUsd(pricedRows.reduce((total, amount) => total + amount, 0))
661
+ : null,
662
+ pricedSubs: pricedRows.length,
663
+ totalSubs: committedRows.length
664
+ },
579
665
  overage: mode === "metered" || mode === "mixed" ? overage : null,
580
666
  coverage,
581
667
  networkUploaded: false
582
668
  });
583
669
  }
670
+ /** Detected-plan list price (subscriptionPlans); null when unpriced. */
671
+ function committedPriceForPlanId(planId) {
672
+ if (!planId)
673
+ return null;
674
+ return subscriptionPlans.find((plan) => plan.id === planId)?.monthlyUsd ?? null;
675
+ }
676
+ function missingBilledWindow() {
677
+ return {
678
+ amountUsd: null,
679
+ recordCount: 0,
680
+ basis: "provider_billed",
681
+ financialEvidence: "missing",
682
+ coverage: "missing"
683
+ };
684
+ }
685
+ /**
686
+ * The v1 dual-write payload (C-lane §2.1 fleet back-compat): today's fields
687
+ * only, so an already-installed v1 runner keeps rendering fresh data instead
688
+ * of decaying into permanent staleness. Exact v1 key set; no v2 fields.
689
+ */
690
+ export function activitySnapshotV1Payload(snapshot) {
691
+ return {
692
+ kind: snapshot.kind,
693
+ schemaVersion: 1,
694
+ currency: snapshot.currency,
695
+ asOf: snapshot.asOf,
696
+ generatedAt: snapshot.generatedAt,
697
+ lastAttemptAt: snapshot.lastAttemptAt,
698
+ lastSuccessAt: snapshot.lastSuccessAt,
699
+ refresh: snapshot.refresh,
700
+ mode: snapshot.mode,
701
+ subscription: snapshot.subscription
702
+ ? {
703
+ agents: snapshot.subscription.agents.map((agent) => ({
704
+ agent: agent.agent,
705
+ billing: agent.billing,
706
+ planId: agent.planId,
707
+ apiEquivalent: agent.apiEquivalent,
708
+ limits: agent.limits,
709
+ pressure: agent.pressure
710
+ }))
711
+ }
712
+ : null,
713
+ metered: snapshot.metered,
714
+ unresolved: snapshot.unresolved,
715
+ overage: snapshot.overage,
716
+ coverage: snapshot.coverage,
717
+ networkUploaded: snapshot.networkUploaded
718
+ };
719
+ }
584
720
  /** A bounded no-evidence state for an initial failed refresh. */
585
721
  export function createActivitySnapshotError(attemptedAt, errorCode) {
586
722
  const timestamp = new Date(parseTimestamp(attemptedAt, "attemptedAt")).toISOString();
587
723
  return activitySnapshotSchema.parse({
588
724
  kind: "aibill.activity_snapshot",
589
- schemaVersion: 1,
725
+ schemaVersion: 2,
590
726
  currency: "USD",
591
727
  asOf: timestamp,
592
728
  generatedAt: timestamp,
@@ -597,6 +733,8 @@ export function createActivitySnapshotError(attemptedAt, errorCode) {
597
733
  subscription: null,
598
734
  metered: null,
599
735
  unresolved: null,
736
+ providers: null,
737
+ committedTotal: { amountUsd: null, pricedSubs: 0, totalSubs: 0 },
600
738
  overage: null,
601
739
  coverage: {
602
740
  agents: [],
@@ -650,6 +788,7 @@ function activitySubscriptionAgents(records, calls, allCalls, plans, scans, trus
650
788
  agent,
651
789
  billing: "subscription",
652
790
  planId: isKnownPlanId(plan.planId) ? plan.planId : null,
791
+ committedUsdPerMonth: committedPriceForPlanId(plan.planId),
653
792
  apiEquivalent: buildApiWindows(agentRecords, allCalls.filter((call) => call.agent === agent), trustedProviderIds, asOfMs, localCoverageForAgent(agent, scans)),
654
793
  limits: latestReportedLimits(agentCalls, asOfMs),
655
794
  pressure: plan.limitSignal === "extra-usage credits exhausted"
@@ -1,6 +1,13 @@
1
1
  import { type ActivitySnapshot, type ActivitySnapshotRefreshErrorCode } from "./activitySnapshot.js";
2
2
  export declare const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
3
- export declare const activitySnapshotCacheFileName = "statusline-v1.json";
3
+ /** The v2 snapshot cache (C-lane §2.1). */
4
+ export declare const activitySnapshotCacheFileName = "statusline-v2.json";
5
+ /**
6
+ * Deprecation-window dual-write target: installed v1 runners are frozen
7
+ * copies that read only this file, so every v2 write also refreshes a v1
8
+ * payload here (today's fields only) instead of stranding them stale.
9
+ */
10
+ export declare const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
4
11
  export declare const activitySnapshotCacheMaxBytes: number;
5
12
  export type ActivitySnapshotCacheOptions = {
6
13
  /** Test/embedding override. Production defaults to ~/.aibill/cache. */
@@ -1,18 +1,30 @@
1
1
  import { constants } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
2
3
  import { open, lstat, mkdir, chmod, realpath, rename, unlink } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { setTimeout as delay } from "node:timers/promises";
7
- import { activitySnapshotSchema, createActivitySnapshotError } from "./activitySnapshot.js";
8
+ import { promisify } from "node:util";
9
+ import { activitySnapshotSchema, activitySnapshotV1Payload, createActivitySnapshotError } from "./activitySnapshot.js";
8
10
  export const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
9
- export const activitySnapshotCacheFileName = "statusline-v1.json";
11
+ /** The v2 snapshot cache (C-lane §2.1). */
12
+ export const activitySnapshotCacheFileName = "statusline-v2.json";
13
+ /**
14
+ * Deprecation-window dual-write target: installed v1 runners are frozen
15
+ * copies that read only this file, so every v2 write also refreshes a v1
16
+ * payload here (today's fields only) instead of stranding them stale.
17
+ */
18
+ export const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
10
19
  export const activitySnapshotCacheMaxBytes = 64 * 1_024;
20
+ // The lock name is shared with pre-v2 writers on purpose: during the fleet
21
+ // deprecation window both CLI generations serialize through one lock.
11
22
  const lockFileName = ".statusline-v1.lock";
12
23
  const defaultLockTimeoutMs = 2_000;
13
24
  const staleLockMs = 15_000;
14
25
  const lockPollMs = 20;
15
26
  const lockMetadataMaxBytes = 512;
27
+ const execFile = promisify(execFileCallback);
16
28
  export class ActivitySnapshotCacheError extends Error {
17
29
  code;
18
30
  constructor(code, message) {
@@ -207,7 +219,7 @@ async function readSnapshotFile(directory) {
207
219
  catch {
208
220
  return { status: "error", code: "malformed" };
209
221
  }
210
- if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 1) {
222
+ if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 2) {
211
223
  return { status: "error", code: "unsupported_version" };
212
224
  }
213
225
  const parsed = activitySnapshotSchema.safeParse(value);
@@ -225,11 +237,17 @@ async function readSnapshotFile(directory) {
225
237
  }
226
238
  }
227
239
  async function atomicWriteSnapshot(directory, snapshot) {
228
- const contents = `${JSON.stringify(snapshot)}\n`;
240
+ await atomicWriteCacheFile(directory, activitySnapshotCacheFileName, `${JSON.stringify(snapshot)}\n`);
241
+ // C-lane §2.1 fleet back-compat: dual-write the v1 payload so an
242
+ // already-installed v1 runner keeps rendering fresh data during the
243
+ // deprecation window instead of decaying into permanent staleness.
244
+ await atomicWriteCacheFile(directory, activitySnapshotLegacyCacheFileName, `${JSON.stringify(activitySnapshotV1Payload(snapshot))}\n`);
245
+ }
246
+ async function atomicWriteCacheFile(directory, fileName, contents) {
229
247
  if (Buffer.byteLength(contents, "utf8") > activitySnapshotCacheMaxBytes) {
230
248
  throw new ActivitySnapshotCacheError("invalid_snapshot", "Activity snapshot exceeds the 64 KiB cache limit.");
231
249
  }
232
- const filePath = join(directory, activitySnapshotCacheFileName);
250
+ const filePath = join(directory, fileName);
233
251
  const existing = await lstat(filePath).catch((error) => {
234
252
  if (isNodeError(error, "ENOENT"))
235
253
  return undefined;
@@ -238,7 +256,7 @@ async function atomicWriteSnapshot(directory, snapshot) {
238
256
  if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
239
257
  throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot cache path is not a regular file.");
240
258
  }
241
- const temporaryPath = join(directory, `.${activitySnapshotCacheFileName}.${process.pid}.${randomUUID()}.tmp`);
259
+ const temporaryPath = join(directory, `.${fileName}.${process.pid}.${randomUUID()}.tmp`);
242
260
  let handle;
243
261
  try {
244
262
  handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
@@ -329,6 +347,84 @@ async function ensureDefaultParent(homeDirectory, create) {
329
347
  }
330
348
  if (create)
331
349
  await chmod(parent, 0o700);
350
+ await ensureDefaultCacheGitPrivacy(parent, create);
351
+ }
352
+ /**
353
+ * When a synthetic or real HOME is itself inside a Git worktree, protect the
354
+ * complete top-level private state directory before any cache child is
355
+ * created. Explicit AIBILL_CACHE_DIR/cacheDirectory overrides intentionally
356
+ * remain caller-owned and never receive repository files from this helper.
357
+ */
358
+ async function ensureDefaultCacheGitPrivacy(aibillDirectory, create) {
359
+ const gitRoot = await findEnclosingGitRoot(aibillDirectory);
360
+ if (!gitRoot)
361
+ return;
362
+ const marker = join(aibillDirectory, ".gitignore");
363
+ let handle;
364
+ try {
365
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
366
+ }
367
+ catch (error) {
368
+ if (!isNodeError(error, "ENOENT"))
369
+ throw error;
370
+ if (!create) {
371
+ const missing = new Error("Private aibill Git privacy marker does not exist.");
372
+ missing.code = "ENOENT";
373
+ throw missing;
374
+ }
375
+ handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
376
+ await handle.writeFile("*\n", "utf8");
377
+ await handle.sync();
378
+ await handle.close();
379
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
380
+ }
381
+ try {
382
+ const info = await handle.stat();
383
+ if (!info.isFile() || !hasPrivatePermissions(info.mode) || info.size !== 2) {
384
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is unsafe.");
385
+ }
386
+ const buffer = Buffer.alloc(2);
387
+ const { bytesRead } = await handle.read(buffer, 0, 2, 0);
388
+ if (bytesRead !== 2 || buffer.toString("utf8") !== "*\n") {
389
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is invalid.");
390
+ }
391
+ }
392
+ finally {
393
+ await handle.close().catch(() => undefined);
394
+ }
395
+ const relativeDirectory = relative(gitRoot, aibillDirectory);
396
+ const tracked = await execFile("git", ["-C", gitRoot, "ls-files", "--", relativeDirectory], {
397
+ encoding: "utf8",
398
+ maxBuffer: 64 * 1024
399
+ }).then(({ stdout }) => stdout.trim()).catch(() => {
400
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache tracking status could not be verified.");
401
+ });
402
+ if (tracked) {
403
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is already tracked by Git.");
404
+ }
405
+ const ignored = await execFile("git", [
406
+ "-C", gitRoot, "check-ignore", "--quiet", "--no-index", "--",
407
+ join(relativeDirectory, "cache", "privacy-probe.json")
408
+ ]).then(() => true).catch(() => false);
409
+ if (!ignored) {
410
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is not proven ignored by Git.");
411
+ }
412
+ }
413
+ async function findEnclosingGitRoot(path) {
414
+ let current = resolve(path);
415
+ while (true) {
416
+ const gitEntry = await lstat(join(current, ".git")).catch((error) => {
417
+ if (isNodeError(error, "ENOENT") || isNodeError(error, "ENOTDIR"))
418
+ return undefined;
419
+ throw error;
420
+ });
421
+ if (gitEntry)
422
+ return current;
423
+ const parent = dirname(current);
424
+ if (parent === current)
425
+ return undefined;
426
+ current = parent;
427
+ }
332
428
  }
333
429
  function configuredCacheDirectory(options) {
334
430
  const configured = options.cacheDirectory?.trim() ||
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `ab1.` agent-draft token: how a conversationally drafted improve plan
3
+ * travels from `draft_improve_command` (MCP, read-only) to `aibill improve
4
+ * --draft` (terminal, human-approved) as ONE argv token.
5
+ *
6
+ * Design: AGENT_NATIVE_LOOP_DESIGN.md §2a (REV 2, QA-PASSED). The base64url
7
+ * alphabet contains no shell metacharacter, quote, or whitespace, so the
8
+ * token cannot break out of its argv slot in sh/bash/zsh/fish/pwsh and
9
+ * cannot be mangled by smart quotes, wrapping, or locale. Decoding NEVER
10
+ * throws — every failure is a tagged reason so a bad draft is set aside
11
+ * with copy, not a crash.
12
+ */
13
+ /**
14
+ * AUTHORITATIVE bound (m11): the whole token is at most 20,000 characters,
15
+ * which implies decoded JSON ≤ ~15,000 bytes. There is no separate
16
+ * decoded-byte cap.
17
+ */
18
+ export declare const MAX_AGENT_DRAFT_TOKEN_CHARS = 20000;
19
+ export type AgentDraftV1 = {
20
+ v: 1;
21
+ experimentId: string;
22
+ revisionId: string;
23
+ change: string;
24
+ rollback: string;
25
+ canary: string;
26
+ };
27
+ export type AgentDraftDecodeFailureReason = "not_a_token" | "token_too_long" | "not_base64url_json" | "not_a_plain_object" | "unexpected_keys" | "unsupported_version" | "invalid_experiment_id" | "invalid_revision_id" | "invalid_sentence";
28
+ export type AgentDraftDecodeResult = {
29
+ ok: true;
30
+ draft: AgentDraftV1;
31
+ } | {
32
+ ok: false;
33
+ reason: AgentDraftDecodeFailureReason;
34
+ };
35
+ /** Cheap argv-time shape check shared with parseArgs (full decode comes later). */
36
+ export declare function looksLikeAgentDraftToken(value: string): boolean;
37
+ /**
38
+ * Decode and structurally validate an `ab1.` token. Hardening (m11, exact
39
+ * spec): the payload must JSON.parse to a plain object; keys are compared
40
+ * as a strict SET against the six expected keys (`Object.keys` DOES surface
41
+ * `__proto__` as an own key after JSON.parse, so `__proto__`/`constructor`/
42
+ * any extra key fails the set check); values are copied field-by-field onto
43
+ * a fresh null-prototype object before any further use. JSON duplicate keys
44
+ * are last-win in JSON.parse and undetectable post-parse: the decoded
45
+ * object is declared authoritative, and every value still passes the
46
+ * classifier gate afterwards.
47
+ */
48
+ export declare function decodeAgentDraftTokenV1(token: string): AgentDraftDecodeResult;
49
+ export type AgentDraftEncodeResult = {
50
+ ok: true;
51
+ token: string;
52
+ } | {
53
+ ok: false;
54
+ reason: AgentDraftDecodeFailureReason;
55
+ };
56
+ /**
57
+ * Compose an `ab1.` token from validated fields. The only sanctioned caller
58
+ * is `draft_improve_command`; encoding enforces the same structural rules as
59
+ * decoding so a composing bug cannot emit an undecodable token.
60
+ */
61
+ export declare function encodeAgentDraftTokenV1(draft: Omit<AgentDraftV1, "v">): AgentDraftEncodeResult;
62
+ export type AgentDraftSentenceVerdict = {
63
+ ok: true;
64
+ value: string;
65
+ } | {
66
+ ok: false;
67
+ reason: string;
68
+ };
69
+ /**
70
+ * The ONE screening path a drafted plan sentence takes, used verbatim by
71
+ * `draft_improve_command` at composition and by `improve --draft` before a
72
+ * prefill can render: sanitize exactly like typed input, then classify with
73
+ * the shared hardened prose classifier. Because both surfaces call this
74
+ * function, MCP-preview and CLI-gate verdicts cannot diverge (QA 12).
75
+ *
76
+ * Rejection reasons are the terminal's own reprompt copy; credential
77
+ * rejections never echo the text.
78
+ */
79
+ export declare function screenAgentDraftSentence(sentence: string): AgentDraftSentenceVerdict;
80
+ //# sourceMappingURL=agentDraftToken.d.ts.map
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The `ab1.` agent-draft token: how a conversationally drafted improve plan
3
+ * travels from `draft_improve_command` (MCP, read-only) to `aibill improve
4
+ * --draft` (terminal, human-approved) as ONE argv token.
5
+ *
6
+ * Design: AGENT_NATIVE_LOOP_DESIGN.md §2a (REV 2, QA-PASSED). The base64url
7
+ * alphabet contains no shell metacharacter, quote, or whitespace, so the
8
+ * token cannot break out of its argv slot in sh/bash/zsh/fish/pwsh and
9
+ * cannot be mangled by smart quotes, wrapping, or locale. Decoding NEVER
10
+ * throws — every failure is a tagged reason so a bad draft is set aside
11
+ * with copy, not a crash.
12
+ */
13
+ import { classifyGuidedAnswer, looksLikeCredential } from "./guidedAnswer.js";
14
+ import { sanitizeLocalActivityText } from "./localAgentLogs.js";
15
+ /** `ab1` = aibill draft v1; `.` is outside base64url so the prefix is unambiguous. */
16
+ const TOKEN_PREFIX = "ab1.";
17
+ /**
18
+ * AUTHORITATIVE bound (m11): the whole token is at most 20,000 characters,
19
+ * which implies decoded JSON ≤ ~15,000 bytes. There is no separate
20
+ * decoded-byte cap.
21
+ */
22
+ export const MAX_AGENT_DRAFT_TOKEN_CHARS = 20_000;
23
+ const TOKEN_SHAPE = /^ab1\.[A-Za-z0-9_-]{16,}$/;
24
+ const EXPERIMENT_ID_SHAPE = /^tre_v0_[a-f0-9]{64}$/;
25
+ // The design sketched {1,64}, but real revision ids are `trev_v0_<64hex>`
26
+ // (72 chars, actionVerification.ts L124) — widened to 128, same charset.
27
+ const REVISION_ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
28
+ /** Single-line plain text: no C0/C1 controls (same refine the MCP schema uses). */
29
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/;
30
+ const MAX_SENTENCE_CHARS = 1_000;
31
+ const EXPECTED_KEYS = [
32
+ "canary", "change", "experimentId", "revisionId", "rollback", "v"
33
+ ];
34
+ /** Cheap argv-time shape check shared with parseArgs (full decode comes later). */
35
+ export function looksLikeAgentDraftToken(value) {
36
+ return value.length <= MAX_AGENT_DRAFT_TOKEN_CHARS && TOKEN_SHAPE.test(value);
37
+ }
38
+ function validSentence(value) {
39
+ return typeof value === "string" &&
40
+ value.length >= 1 &&
41
+ value.length <= MAX_SENTENCE_CHARS &&
42
+ !CONTROL_CHARACTERS.test(value);
43
+ }
44
+ /**
45
+ * Decode and structurally validate an `ab1.` token. Hardening (m11, exact
46
+ * spec): the payload must JSON.parse to a plain object; keys are compared
47
+ * as a strict SET against the six expected keys (`Object.keys` DOES surface
48
+ * `__proto__` as an own key after JSON.parse, so `__proto__`/`constructor`/
49
+ * any extra key fails the set check); values are copied field-by-field onto
50
+ * a fresh null-prototype object before any further use. JSON duplicate keys
51
+ * are last-win in JSON.parse and undetectable post-parse: the decoded
52
+ * object is declared authoritative, and every value still passes the
53
+ * classifier gate afterwards.
54
+ */
55
+ export function decodeAgentDraftTokenV1(token) {
56
+ if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
57
+ return { ok: false, reason: "not_a_token" };
58
+ }
59
+ if (token.length > MAX_AGENT_DRAFT_TOKEN_CHARS) {
60
+ return { ok: false, reason: "token_too_long" };
61
+ }
62
+ if (!TOKEN_SHAPE.test(token)) {
63
+ return { ok: false, reason: "not_a_token" };
64
+ }
65
+ let parsed;
66
+ try {
67
+ const payload = Buffer.from(token.slice(TOKEN_PREFIX.length), "base64url");
68
+ parsed = JSON.parse(payload.toString("utf8"));
69
+ }
70
+ catch {
71
+ return { ok: false, reason: "not_base64url_json" };
72
+ }
73
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
74
+ return { ok: false, reason: "not_a_plain_object" };
75
+ }
76
+ const keys = Object.keys(parsed).sort();
77
+ if (keys.length !== EXPECTED_KEYS.length ||
78
+ keys.some((key, index) => key !== EXPECTED_KEYS[index])) {
79
+ return { ok: false, reason: "unexpected_keys" };
80
+ }
81
+ // Field-by-field copy onto a null-prototype object; the parsed object is
82
+ // never spread or merged into a prototyped object.
83
+ const record = parsed;
84
+ const draft = Object.assign(Object.create(null), {
85
+ v: 1,
86
+ experimentId: "",
87
+ revisionId: "",
88
+ change: "",
89
+ rollback: "",
90
+ canary: ""
91
+ });
92
+ if (record.v !== 1)
93
+ return { ok: false, reason: "unsupported_version" };
94
+ if (typeof record.experimentId !== "string" ||
95
+ !EXPERIMENT_ID_SHAPE.test(record.experimentId)) {
96
+ return { ok: false, reason: "invalid_experiment_id" };
97
+ }
98
+ if (typeof record.revisionId !== "string" ||
99
+ !REVISION_ID_SHAPE.test(record.revisionId)) {
100
+ return { ok: false, reason: "invalid_revision_id" };
101
+ }
102
+ if (!validSentence(record.change) || !validSentence(record.rollback) ||
103
+ !validSentence(record.canary)) {
104
+ return { ok: false, reason: "invalid_sentence" };
105
+ }
106
+ draft.experimentId = record.experimentId;
107
+ draft.revisionId = record.revisionId;
108
+ draft.change = record.change;
109
+ draft.rollback = record.rollback;
110
+ draft.canary = record.canary;
111
+ return { ok: true, draft };
112
+ }
113
+ /**
114
+ * Compose an `ab1.` token from validated fields. The only sanctioned caller
115
+ * is `draft_improve_command`; encoding enforces the same structural rules as
116
+ * decoding so a composing bug cannot emit an undecodable token.
117
+ */
118
+ export function encodeAgentDraftTokenV1(draft) {
119
+ if (!EXPERIMENT_ID_SHAPE.test(draft.experimentId)) {
120
+ return { ok: false, reason: "invalid_experiment_id" };
121
+ }
122
+ if (!REVISION_ID_SHAPE.test(draft.revisionId)) {
123
+ return { ok: false, reason: "invalid_revision_id" };
124
+ }
125
+ if (!validSentence(draft.change) || !validSentence(draft.rollback) ||
126
+ !validSentence(draft.canary)) {
127
+ return { ok: false, reason: "invalid_sentence" };
128
+ }
129
+ const payload = JSON.stringify({
130
+ v: 1,
131
+ experimentId: draft.experimentId,
132
+ revisionId: draft.revisionId,
133
+ change: draft.change,
134
+ rollback: draft.rollback,
135
+ canary: draft.canary
136
+ });
137
+ const token = TOKEN_PREFIX + Buffer.from(payload, "utf8").toString("base64url");
138
+ if (token.length > MAX_AGENT_DRAFT_TOKEN_CHARS) {
139
+ return { ok: false, reason: "token_too_long" };
140
+ }
141
+ return { ok: true, token };
142
+ }
143
+ /**
144
+ * The ONE screening path a drafted plan sentence takes, used verbatim by
145
+ * `draft_improve_command` at composition and by `improve --draft` before a
146
+ * prefill can render: sanitize exactly like typed input, then classify with
147
+ * the shared hardened prose classifier. Because both surfaces call this
148
+ * function, MCP-preview and CLI-gate verdicts cannot diverge (QA 12).
149
+ *
150
+ * Rejection reasons are the terminal's own reprompt copy; credential
151
+ * rejections never echo the text.
152
+ */
153
+ export function screenAgentDraftSentence(sentence) {
154
+ // A credential anywhere in the RAW draft sets the whole field aside (the
155
+ // never-echoed A3 fallback path). Sanitizing it away and accepting the
156
+ // mutated remainder would record a sentence nobody wrote under the
157
+ // "Drafted with your agent" label (impl QA m-1).
158
+ if (looksLikeCredential(sentence)) {
159
+ return {
160
+ ok: false,
161
+ reason: "That draft contains something credential-shaped. aibill never stores credentials — the draft was set aside."
162
+ };
163
+ }
164
+ // Sanitize before classification so no rejection reason can echo raw
165
+ // fragments. An over-long sentence is rejected by the classifier's own
166
+ // length rule, with its own copy — never silently truncated.
167
+ const sanitized = sanitizeLocalActivityText(sentence).trim();
168
+ const verdict = classifyGuidedAnswer("prose", sanitized);
169
+ if (verdict.outcome === "accept") {
170
+ // `keep` is a terminal-only escape hatch, meaningless in a draft.
171
+ if (verdict.value === "keep") {
172
+ return {
173
+ ok: false,
174
+ reason: "That is aibill's own reserved vocabulary, not a plan sentence."
175
+ };
176
+ }
177
+ return { ok: true, value: verdict.value };
178
+ }
179
+ if (verdict.outcome === "reject") {
180
+ return { ok: false, reason: verdict.message };
181
+ }
182
+ // navigate/skip: the sentence collided with reserved navigation words.
183
+ return {
184
+ ok: false,
185
+ reason: "That is aibill's own navigation vocabulary (back/cancel), not a plan sentence."
186
+ };
187
+ }
188
+ //# sourceMappingURL=agentDraftToken.js.map