ai-spend-agent 0.9.0 → 0.9.2
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/guidedPrompt.d.ts +2 -27
- package/dist/guidedPrompt.js +5 -282
- package/dist/improveFlow.d.ts +34 -4
- package/dist/improveFlow.js +44 -16
- package/dist/index.d.ts +11 -0
- package/dist/index.js +972 -83
- package/dist/projectAccountabilityState.d.ts +6 -7
- package/dist/projectAccountabilityState.js +6 -15
- package/dist/signup.d.ts +272 -0
- package/dist/signup.js +681 -0
- package/dist/statuslineRuntime.js +3 -1
- package/dist/telemetry.d.ts +149 -0
- package/dist/telemetry.js +390 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource, renderForYourAgent } from "./guidedPrompt.js";
|
|
9
|
+
import { assessEmailDeliverability, buildWaitlistRef, normalizeWaitlistEmail, postWaitlistSignup, readSignupState, clearSignupState, sanitizeSignupRefTag, serializeWaitlistPayload, signupCopy, signupStateFilePath, writeSignupState } from "./signup.js";
|
|
10
|
+
import { killTelemetryForThisProcess, readTelemetryState, telemetryDisclosureLine, telemetryStateFilePath, writeTelemetryState } from "./telemetry.js";
|
|
8
11
|
import { parsePlanDraft, renderCleanExit, runIdentitySequence, runPlanSitting, runQualitySitting, runRecordSitting, runRollbackSitting, runStartSitting, shortSittingHint } from "./improveFlow.js";
|
|
9
|
-
import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
12
|
+
import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, decodeAgentDraftTokenV1, IMPROVE_USER_SAFETY_LINE_V1, looksLikeAgentDraftToken, screenAgentDraftSentence, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, providerAccountKey, tagProviderAccountRecords, retainProviderRecordsForNewSync, providerAccountSlices, formatProviderAccountSlices, intersectProviderCoverageIntervals, duplicateProviderAccountSliceWarnings, providerSliceReplacementNotices, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, activitySnapshotCachePath, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
10
13
|
import { StatuslineInstallerError, installClaudeStatusline, refreshOwnedStatuslineRunner, uninstallClaudeStatusline } from "./statuslineInstaller.js";
|
|
11
14
|
import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
|
|
12
15
|
import { chooseLatestTokenReductionExperiment, loadTokenVerificationState, upsertTokenReductionExperiment } from "./tokenVerificationState.js";
|
|
@@ -30,7 +33,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
30
33
|
return ok(await cliVersion());
|
|
31
34
|
}
|
|
32
35
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
33
|
-
return ok(helpText());
|
|
36
|
+
return ok(helpText(runtime.telemetryDisclosure));
|
|
34
37
|
}
|
|
35
38
|
const args = parseArgs(argv);
|
|
36
39
|
if (args.parseErrors.length > 0) {
|
|
@@ -68,7 +71,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
68
71
|
return quickstartCommand(args, runtime);
|
|
69
72
|
}
|
|
70
73
|
if (args.command === "doctor") {
|
|
71
|
-
return doctorCommand(args);
|
|
74
|
+
return doctorCommand(args, runtime);
|
|
72
75
|
}
|
|
73
76
|
if (args.command === "reset") {
|
|
74
77
|
return resetCommand(args);
|
|
@@ -79,6 +82,12 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
79
82
|
if (args.command === "statusline") {
|
|
80
83
|
return statuslineCommand(args, runtime);
|
|
81
84
|
}
|
|
85
|
+
if (args.command === "signup") {
|
|
86
|
+
return signupCommand(args, runtime);
|
|
87
|
+
}
|
|
88
|
+
if (args.command === "telemetry") {
|
|
89
|
+
return telemetryCommand(args, runtime);
|
|
90
|
+
}
|
|
82
91
|
if (args.command === "scan") {
|
|
83
92
|
return scanCommand(args);
|
|
84
93
|
}
|
|
@@ -89,7 +98,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
89
98
|
return watchCommand(args);
|
|
90
99
|
}
|
|
91
100
|
if (args.command === "report") {
|
|
92
|
-
return reportCommand(args);
|
|
101
|
+
return reportCommand(args, runtime);
|
|
93
102
|
}
|
|
94
103
|
if (args.command === "report-card") {
|
|
95
104
|
return reportCardCommand(args);
|
|
@@ -98,7 +107,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
98
107
|
return glanceCommand(args);
|
|
99
108
|
}
|
|
100
109
|
if (args.command === "context" || args.command === "context-health") {
|
|
101
|
-
return contextHealthCommand(args);
|
|
110
|
+
return contextHealthCommand(args, runtime);
|
|
102
111
|
}
|
|
103
112
|
if (args.command === "apply-artifact" || args.command === "apply") {
|
|
104
113
|
return applyArtifactCommand(args);
|
|
@@ -133,13 +142,16 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
133
142
|
if (args.command === "sync-provider") {
|
|
134
143
|
return syncProviderCommand(args);
|
|
135
144
|
}
|
|
145
|
+
if (args.command === "drop-slice") {
|
|
146
|
+
return dropSliceCommand(args);
|
|
147
|
+
}
|
|
136
148
|
if (args.command === "confirm-mapping") {
|
|
137
149
|
return confirmMappingCommand(args);
|
|
138
150
|
}
|
|
139
151
|
return {
|
|
140
152
|
exitCode: 1,
|
|
141
153
|
stdout: "",
|
|
142
|
-
stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText()}`
|
|
154
|
+
stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText(runtime.telemetryDisclosure)}`
|
|
143
155
|
};
|
|
144
156
|
}
|
|
145
157
|
async function quickstartCommand(args, runtime = {}) {
|
|
@@ -153,14 +165,21 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
153
165
|
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
154
166
|
};
|
|
155
167
|
}
|
|
156
|
-
const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, financialCoverageComplete } = await loadInstantReadData(args);
|
|
168
|
+
const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, localFinancialRecords, financialCoverageComplete } = await loadInstantReadData(args);
|
|
157
169
|
if (records.length === 0) {
|
|
158
|
-
return noEvidenceResult("receipt", warnings, sinceDays);
|
|
170
|
+
return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
|
|
159
171
|
}
|
|
160
172
|
const summaryRecords = mode === "connected"
|
|
161
173
|
? selectProviderFinancialHeadlineRecords(records)
|
|
162
174
|
: records;
|
|
163
175
|
const summary = analyzeSpend(summaryRecords);
|
|
176
|
+
// Connected receipts stay billed-primary but never ERASE the estimated
|
|
177
|
+
// axis: local transcript records ride along so subscription rows keep
|
|
178
|
+
// their ~ API-equivalent figures next to billed money (C-lane §1.4). The
|
|
179
|
+
// renderer classifies each record by basis and never blends the totals.
|
|
180
|
+
const receiptRecords = mode === "connected" && (localFinancialRecords?.length ?? 0) > 0
|
|
181
|
+
? [...summaryRecords, ...localFinancialRecords]
|
|
182
|
+
: summaryRecords;
|
|
164
183
|
// For real local-log users the by-project view is the flagship table
|
|
165
184
|
// ("which project burns my plan"); demo/connected keep by-model.
|
|
166
185
|
const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
|
|
@@ -241,10 +260,14 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
241
260
|
}).catch(() => undefined)
|
|
242
261
|
: undefined;
|
|
243
262
|
const summaryText = generatePlainEnglishSummary(summary, {
|
|
244
|
-
records:
|
|
263
|
+
records: receiptRecords,
|
|
245
264
|
groupBy,
|
|
246
265
|
color,
|
|
247
266
|
mode,
|
|
267
|
+
// Receipt-line truth: with telemetry enabled+noticed the receipt's
|
|
268
|
+
// privacy claim discloses the command counts instead of "nothing
|
|
269
|
+
// uploaded".
|
|
270
|
+
...(runtime.telemetryDisclosure === true ? { telemetryDisclosureLine } : {}),
|
|
248
271
|
...(providerCoverage ? { providerCoverage } : {}),
|
|
249
272
|
nextSteps,
|
|
250
273
|
deadContext,
|
|
@@ -509,7 +532,7 @@ async function glanceCommand(args) {
|
|
|
509
532
|
});
|
|
510
533
|
return ok(JSON.stringify(snapshot));
|
|
511
534
|
}
|
|
512
|
-
async function contextHealthCommand(args) {
|
|
535
|
+
async function contextHealthCommand(args, runtime = {}) {
|
|
513
536
|
const sinceDays = args.sinceDays ?? 30;
|
|
514
537
|
if (!validSinceDays(sinceDays))
|
|
515
538
|
return invalidSinceDaysResult();
|
|
@@ -534,9 +557,9 @@ async function contextHealthCommand(args) {
|
|
|
534
557
|
const qualitativeCoverage = summarizeCliQualitativeCoverage(logs);
|
|
535
558
|
return ok(args.json
|
|
536
559
|
? JSON.stringify({ ...health, qualitativeCoverage })
|
|
537
|
-
: `${renderContextHealth(health)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
|
|
560
|
+
: `${renderContextHealth(health, runtime.telemetryDisclosure)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
|
|
538
561
|
}
|
|
539
|
-
function renderContextHealth(health) {
|
|
562
|
+
function renderContextHealth(health, telemetryDisclosure) {
|
|
540
563
|
const status = health.status.replace("_", " ").toUpperCase();
|
|
541
564
|
const activation = health.activation;
|
|
542
565
|
const dead = health.deadContext;
|
|
@@ -581,7 +604,7 @@ function renderContextHealth(health) {
|
|
|
581
604
|
lines.push(` - ${evidence.summary} [${evidence.confidence}; ${evidence.source}]`);
|
|
582
605
|
}
|
|
583
606
|
}
|
|
584
|
-
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.",
|
|
607
|
+
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", `Privacy: ${uploadsNothingLine(telemetryDisclosure, "this CLI run uploads nothing.")}`);
|
|
585
608
|
return lines.join("\n");
|
|
586
609
|
}
|
|
587
610
|
function quickstartNextSteps(mode, detected) {
|
|
@@ -595,10 +618,17 @@ function quickstartNextSteps(mode, detected) {
|
|
|
595
618
|
steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
|
|
596
619
|
}
|
|
597
620
|
steps.push(mode === "demo"
|
|
598
|
-
|
|
621
|
+
// Every printed command must run as printed: the demo workspace does
|
|
622
|
+
// not exist yet, so the command creates it first (shipped-audit fix).
|
|
623
|
+
? "mkdir -p ./demo-workspace && npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
|
|
599
624
|
: "npx aibill report write a shareable Markdown + HTML report");
|
|
600
625
|
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
601
626
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
|
|
627
|
+
if (mode === "demo") {
|
|
628
|
+
// Static pointer only — sample output is built for recordings and
|
|
629
|
+
// screenshots, so it never prompts (capture design moments map).
|
|
630
|
+
steps.push(signupCopy.samplePointer);
|
|
631
|
+
}
|
|
602
632
|
return steps;
|
|
603
633
|
}
|
|
604
634
|
async function readPersistedSpend(rootPath, options = {}) {
|
|
@@ -714,6 +744,12 @@ async function loadInstantReadData(args) {
|
|
|
714
744
|
actionEvidence,
|
|
715
745
|
codexInvocationFiles: actionEvidence.codexInvocationFiles
|
|
716
746
|
} : {}),
|
|
747
|
+
// Billed provider records are the headline, but the machine's local
|
|
748
|
+
// API-equivalent evidence is a separate axis the receipt must keep
|
|
749
|
+
// (per-subscription ~ figures) — connected mode never erases it.
|
|
750
|
+
...(financialLogs && financialLogs.records.length > 0
|
|
751
|
+
? { localFinancialRecords: financialLogs.records }
|
|
752
|
+
: {}),
|
|
717
753
|
...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {}),
|
|
718
754
|
financialCoverageComplete: persisted.providerCoverage === "complete" &&
|
|
719
755
|
connectedHeadlineRecords.length > 0 &&
|
|
@@ -917,7 +953,183 @@ function renderCliQualitativeCoverage(coverage) {
|
|
|
917
953
|
`${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
|
|
918
954
|
`${coverage.skippedForBudget} eligible files skipped by budget`;
|
|
919
955
|
}
|
|
920
|
-
|
|
956
|
+
/**
|
|
957
|
+
* `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
|
|
958
|
+
* the launch list. Sends EXACTLY {email, ref} to the deployed waitlist route
|
|
959
|
+
* after showing the literal payload JSON and receiving a typed `y` on a real
|
|
960
|
+
* terminal. Never sends without the confirm; never retries, queues, or
|
|
961
|
+
* persists the typed email on failure. `--forget` clears local signup state;
|
|
962
|
+
* `--never` records never-ask without sending anything.
|
|
963
|
+
*/
|
|
964
|
+
async function signupCommand(args, runtime) {
|
|
965
|
+
const stateFile = signupStateFilePath(runtime.homeDirectory);
|
|
966
|
+
if (args.signupForget) {
|
|
967
|
+
const cleared = await clearSignupState(stateFile);
|
|
968
|
+
return cleared
|
|
969
|
+
? ok(signupCopy.forgetLine)
|
|
970
|
+
: { exitCode: 1, stdout: "", stderr: "local signup state could not be cleared; check ~/.aibill permissions" };
|
|
971
|
+
}
|
|
972
|
+
const priorRead = await readSignupState(stateFile);
|
|
973
|
+
const priorAskCount = priorRead.kind === "ok" ? priorRead.state.askCount : 0;
|
|
974
|
+
if (args.signupNever) {
|
|
975
|
+
const written = await writeSignupState(stateFile, { version: 1, status: "never", askCount: priorAskCount });
|
|
976
|
+
return written
|
|
977
|
+
? ok(signupCopy.neverLine)
|
|
978
|
+
: { exitCode: 1, stdout: "", stderr: "never-ask could not be persisted; check ~/.aibill permissions" };
|
|
979
|
+
}
|
|
980
|
+
if (!args.signupEmail) {
|
|
981
|
+
return {
|
|
982
|
+
exitCode: 1,
|
|
983
|
+
stdout: "",
|
|
984
|
+
stderr: [
|
|
985
|
+
"signup needs an email: npx aibill signup you@work.com [--ref <token>]",
|
|
986
|
+
"Optional: signup --never (never ask again) · signup --forget (clear local signup state)"
|
|
987
|
+
].join("\n")
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
// Consent requires a human at a real terminal; there is deliberately no
|
|
991
|
+
// --yes flag and no non-TTY path (QA 2).
|
|
992
|
+
if (runtime.interactive !== true || !runtime.prompt) {
|
|
993
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.nonInteractiveLine };
|
|
994
|
+
}
|
|
995
|
+
const email = normalizeWaitlistEmail(args.signupEmail);
|
|
996
|
+
if (email === undefined) {
|
|
997
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.invalidEmailLine };
|
|
998
|
+
}
|
|
999
|
+
// Deliverability (MX with A fallback, 1.5s budget, fail-open on DNS
|
|
1000
|
+
// trouble): only a provable cannot-receive domain or a throwaway inbox is
|
|
1001
|
+
// refused — never a slow or offline resolver.
|
|
1002
|
+
const deliverability = await assessEmailDeliverability(email, {
|
|
1003
|
+
...(runtime.signupDns ? { resolver: runtime.signupDns } : {})
|
|
1004
|
+
});
|
|
1005
|
+
if (deliverability === "no_mx") {
|
|
1006
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.noMxLine };
|
|
1007
|
+
}
|
|
1008
|
+
if (deliverability === "disposable") {
|
|
1009
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.disposableLine };
|
|
1010
|
+
}
|
|
1011
|
+
if (priorRead.kind === "ok" && priorRead.state.status === "subscribed" && priorRead.state.email === email) {
|
|
1012
|
+
// Cosmetic-local dedupe; the route itself is idempotent (201 on duplicate).
|
|
1013
|
+
return ok(signupCopy.alreadyLine);
|
|
1014
|
+
}
|
|
1015
|
+
const payload = { email, ref: buildWaitlistRef("signup", args.signupRef) };
|
|
1016
|
+
const consent = (await runtime.prompt(`${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`)).trim().toLowerCase();
|
|
1017
|
+
if (consent !== "y" && consent !== "yes") {
|
|
1018
|
+
return ok(signupCopy.nothingSentLine);
|
|
1019
|
+
}
|
|
1020
|
+
const outcome = await postWaitlistSignup(payload, { fetchImpl: runtime.waitlistFetch });
|
|
1021
|
+
if (outcome === "sent") {
|
|
1022
|
+
await writeSignupState(stateFile, { version: 1, status: "subscribed", askCount: priorAskCount, email });
|
|
1023
|
+
return ok(signupCopy.sentLine);
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
exitCode: 1,
|
|
1027
|
+
stdout: "",
|
|
1028
|
+
stderr: outcome === "invalid_email"
|
|
1029
|
+
? signupCopy.invalidEmailLine
|
|
1030
|
+
: outcome === "rate_limited"
|
|
1031
|
+
? signupCopy.rateLimitedLine
|
|
1032
|
+
: signupCopy.unreachableLine
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* `aibill telemetry [on|off]` — inspect or switch anonymous command-count
|
|
1037
|
+
* telemetry. Status shows the EXACT last payload verbatim; state is
|
|
1038
|
+
* fail-closed (corrupt/readonly ⇒ off).
|
|
1039
|
+
*/
|
|
1040
|
+
async function telemetryCommand(args, runtime) {
|
|
1041
|
+
const filePath = telemetryStateFilePath(runtime.homeDirectory);
|
|
1042
|
+
const read = await readTelemetryState(filePath);
|
|
1043
|
+
const action = args.telemetryAction;
|
|
1044
|
+
if (action !== undefined && action !== "on" && action !== "off") {
|
|
1045
|
+
return {
|
|
1046
|
+
exitCode: 1,
|
|
1047
|
+
stdout: "",
|
|
1048
|
+
stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: aibill telemetry [on|off]`
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
if (action === "off") {
|
|
1052
|
+
const state = {
|
|
1053
|
+
version: 1,
|
|
1054
|
+
installId: read.kind === "ok" ? read.state.installId : randomUUID(),
|
|
1055
|
+
enabled: false,
|
|
1056
|
+
...(read.kind === "ok" && read.state.noticedAt !== undefined ? { noticedAt: read.state.noticedAt } : {}),
|
|
1057
|
+
...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
|
|
1058
|
+
};
|
|
1059
|
+
if (!await writeTelemetryState(filePath, state)) {
|
|
1060
|
+
// FAIL CLOSED (QA M1): the off could not be persisted and the stored
|
|
1061
|
+
// state may still say enabled — silence THIS process and point at the
|
|
1062
|
+
// env kill-switches for a durable off.
|
|
1063
|
+
killTelemetryForThisProcess();
|
|
1064
|
+
return {
|
|
1065
|
+
exitCode: 1,
|
|
1066
|
+
stdout: "",
|
|
1067
|
+
stderr: [
|
|
1068
|
+
"telemetry off could not be persisted — nothing more will be sent by this run.",
|
|
1069
|
+
"For a durable off, set AI_SPEND_NO_TELEMETRY=1 or DO_NOT_TRACK=1, then fix ~/.aibill permissions."
|
|
1070
|
+
].join("\n")
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
killTelemetryForThisProcess();
|
|
1074
|
+
return ok("telemetry off · nothing is sent");
|
|
1075
|
+
}
|
|
1076
|
+
if (action === "on") {
|
|
1077
|
+
// Turning telemetry on explicitly IS the notice moment: the user is
|
|
1078
|
+
// reading this command's output, so noticedAt is stamped now and
|
|
1079
|
+
// events begin on the next run.
|
|
1080
|
+
const state = {
|
|
1081
|
+
version: 1,
|
|
1082
|
+
installId: read.kind === "ok" ? read.state.installId : randomUUID(),
|
|
1083
|
+
enabled: true,
|
|
1084
|
+
noticedAt: new Date().toISOString(),
|
|
1085
|
+
...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
|
|
1086
|
+
};
|
|
1087
|
+
if (!await writeTelemetryState(filePath, state)) {
|
|
1088
|
+
return {
|
|
1089
|
+
exitCode: 1,
|
|
1090
|
+
stdout: "",
|
|
1091
|
+
stderr: "telemetry state could not be written — telemetry remains off (unreadable state fails closed)"
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
return ok([
|
|
1095
|
+
"telemetry on · anonymous command counts only",
|
|
1096
|
+
`counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp`,
|
|
1097
|
+
"never: arguments, paths, file contents, project names, or your email",
|
|
1098
|
+
"events start with your next run · see payloads anytime: aibill telemetry"
|
|
1099
|
+
].join("\n"));
|
|
1100
|
+
}
|
|
1101
|
+
const lines = ["aibill telemetry"];
|
|
1102
|
+
if (read.kind === "unreadable") {
|
|
1103
|
+
lines.push("status: off (state unreadable — telemetry fails closed)");
|
|
1104
|
+
}
|
|
1105
|
+
else if (read.kind === "fresh") {
|
|
1106
|
+
lines.push("status: not yet noticed · nothing has ever been sent");
|
|
1107
|
+
lines.push("a one-time notice prints after your next interactive run; events begin only after it");
|
|
1108
|
+
}
|
|
1109
|
+
else if (!read.state.enabled) {
|
|
1110
|
+
lines.push("status: off · nothing is sent");
|
|
1111
|
+
}
|
|
1112
|
+
else if (read.state.noticedAt === undefined) {
|
|
1113
|
+
lines.push("status: on but not yet noticed · nothing has been sent");
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
lines.push(`status: on · noticed ${read.state.noticedAt}`);
|
|
1117
|
+
}
|
|
1118
|
+
lines.push("counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp", "never: arguments, paths, file contents, project names, or your email");
|
|
1119
|
+
if (read.kind === "ok" && read.state.lastPayload !== undefined) {
|
|
1120
|
+
lines.push("last payload sent (verbatim):", read.state.lastPayload);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
lines.push("last payload sent: none");
|
|
1124
|
+
}
|
|
1125
|
+
lines.push("switch: aibill telemetry on · aibill telemetry off");
|
|
1126
|
+
return ok(lines.join("\n"));
|
|
1127
|
+
}
|
|
1128
|
+
/** The run-level privacy claim: literal truth in both telemetry states. */
|
|
1129
|
+
function uploadsNothingLine(telemetryDisclosure, original) {
|
|
1130
|
+
return telemetryDisclosure === true ? telemetryDisclosureLine : original;
|
|
1131
|
+
}
|
|
1132
|
+
function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
|
|
921
1133
|
const surfaceLine = surface === "watch"
|
|
922
1134
|
? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
|
|
923
1135
|
: surface === "report-card"
|
|
@@ -931,11 +1143,12 @@ function noEvidenceResult(surface, warnings, sinceDays) {
|
|
|
931
1143
|
"",
|
|
932
1144
|
surfaceLine,
|
|
933
1145
|
"Looked for: Claude Code, Codex, and Gemini CLI local history.",
|
|
934
|
-
"Nothing was uploaded. No sample data was substituted
|
|
1146
|
+
`${uploadsNothingLine(telemetryDisclosure, "Nothing was uploaded.")} No sample data was substituted.`,
|
|
935
1147
|
...warnings.map((warning) => `! ${warning}`),
|
|
936
1148
|
"",
|
|
937
1149
|
"Next",
|
|
938
|
-
" npx aibill doctor --sources see the exact evidence gap and setup paths"
|
|
1150
|
+
" npx aibill doctor --sources see the exact evidence gap and setup paths",
|
|
1151
|
+
` ${signupCopy.receiptPointer}`
|
|
939
1152
|
].join("\n")
|
|
940
1153
|
: "",
|
|
941
1154
|
stderr: surface === "receipt"
|
|
@@ -992,7 +1205,7 @@ function dataModeBanner(mode, records) {
|
|
|
992
1205
|
return "DATA MODE: connected provider billing";
|
|
993
1206
|
return "DATA MODE: demo sample (illustrative — not your real spend)";
|
|
994
1207
|
}
|
|
995
|
-
async function doctorCommand(args) {
|
|
1208
|
+
async function doctorCommand(args, runtime = {}) {
|
|
996
1209
|
if (args.sources) {
|
|
997
1210
|
return doctorSourcesCommand(args);
|
|
998
1211
|
}
|
|
@@ -1057,7 +1270,9 @@ async function doctorCommand(args) {
|
|
|
1057
1270
|
"aibill doctor",
|
|
1058
1271
|
`node version: ${process.version}`,
|
|
1059
1272
|
`cli version: ${await cliVersion()}`,
|
|
1060
|
-
|
|
1273
|
+
runtime.telemetryDisclosure === true
|
|
1274
|
+
? "local-first mode: enabled (evidence stays local · anonymous command counts shared · aibill telemetry off)"
|
|
1275
|
+
: "local-first mode: enabled (no cloud upload, no telemetry)",
|
|
1061
1276
|
`path: ${rootPath}`,
|
|
1062
1277
|
`state directory: ${stateDir}`,
|
|
1063
1278
|
`state mode: ${stateMode}`,
|
|
@@ -1171,10 +1386,22 @@ async function doctorSourcesCommand(args) {
|
|
|
1171
1386
|
.sort((left, right) => right.approvedAt.localeCompare(left.approvedAt))[0];
|
|
1172
1387
|
const stateCheckedAt = attempt?.checkedAt
|
|
1173
1388
|
?? (providerState.provider === id ? providerState.fetchedAt : undefined);
|
|
1389
|
+
// Multi-account honesty: when this provider's records carry account
|
|
1390
|
+
// slices (one Admin key covers ONE organization), name every slice so a
|
|
1391
|
+
// second org's sync is visibly accumulated rather than silently merged.
|
|
1392
|
+
const accountSliceNote = records.some((record) => typeof record.source.account === "string")
|
|
1393
|
+
? ` Account slices: ${formatProviderAccountSlices(providerAccountSlices(records, id))}.`
|
|
1394
|
+
: "";
|
|
1395
|
+
// QA M1: two slices holding identical records are almost certainly one
|
|
1396
|
+
// organization synced under two references — the combined total counts
|
|
1397
|
+
// it twice, so doctor must say so where the slices are listed.
|
|
1398
|
+
const duplicateSliceNote = duplicateProviderAccountSliceWarnings(records, id)
|
|
1399
|
+
.map((warning) => ` WARNING: ${warning}`)
|
|
1400
|
+
.join("");
|
|
1174
1401
|
observations.push({
|
|
1175
1402
|
id,
|
|
1176
1403
|
financialEvidence: evidence,
|
|
1177
|
-
financialEvidenceNote: providerFinancialEvidenceNote(records, evidence)
|
|
1404
|
+
financialEvidenceNote: `${providerFinancialEvidenceNote(records, evidence)}${accountSliceNote}${duplicateSliceNote}`,
|
|
1178
1405
|
// A connector stub is only configuration, not a source check. Older
|
|
1179
1406
|
// successful syncs predate source-status.json, so their non-missing
|
|
1180
1407
|
// registry approval time is the conservative migration fallback.
|
|
@@ -1604,7 +1831,9 @@ async function installStatuslineCommand(args, runtime) {
|
|
|
1604
1831
|
? "aibill statusline is already installed in Claude user settings."
|
|
1605
1832
|
: "aibill statusline installed in Claude user settings.",
|
|
1606
1833
|
"Claude Code: run /status to verify the active setting and every managed source.",
|
|
1607
|
-
"The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence."
|
|
1834
|
+
"The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence.",
|
|
1835
|
+
// Static pointer only — never a prompt (capture design moments map).
|
|
1836
|
+
signupCopy.statuslinePointer
|
|
1608
1837
|
].join("\n"));
|
|
1609
1838
|
}
|
|
1610
1839
|
catch (error) {
|
|
@@ -1733,7 +1962,8 @@ async function initCommand(args, runtime = {}) {
|
|
|
1733
1962
|
trustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true,
|
|
1734
1963
|
untrustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted !== true,
|
|
1735
1964
|
scanError,
|
|
1736
|
-
cacheStatus
|
|
1965
|
+
cacheStatus,
|
|
1966
|
+
...(runtime.telemetryDisclosure !== undefined ? { telemetryDisclosure: runtime.telemetryDisclosure } : {})
|
|
1737
1967
|
});
|
|
1738
1968
|
if (!args.statusline) {
|
|
1739
1969
|
return ok(`${receipt}\noptional Claude Code status line: npx aibill statusline install`);
|
|
@@ -2170,15 +2400,37 @@ function expansionFreshness(snapshot, now) {
|
|
|
2170
2400
|
: seconds < 48 * 3_600
|
|
2171
2401
|
? `${Math.floor(seconds / 3_600)}h`
|
|
2172
2402
|
: `${Math.floor(seconds / 86_400)}d`;
|
|
2173
|
-
|
|
2403
|
+
// Same freshness vocabulary as the statusline runner: state the cache age
|
|
2404
|
+
// as fact instead of calling minutes-old data "stale".
|
|
2405
|
+
return ageMs > 5 * 60 * 1_000 ? `cache ${age} old` : `updated ${age}`;
|
|
2174
2406
|
}
|
|
2175
2407
|
async function preflightInitCache(cacheDirectory) {
|
|
2176
2408
|
const existing = await readActivitySnapshot({ cacheDirectory });
|
|
2177
2409
|
if (existing.status !== "error")
|
|
2178
2410
|
return;
|
|
2411
|
+
// A pre-created but EMPTY cache directory holds nothing to preserve —
|
|
2412
|
+
// typically a user-made dir with default (non-0700) permissions. Init's
|
|
2413
|
+
// own create path re-validates and tightens it to 0700, so proceeding is
|
|
2414
|
+
// safe; only a directory with actual contents aborts (shipped-audit fix).
|
|
2415
|
+
if (existing.code === "unsafe_directory" && await isEmptyRealDirectory(dirname(activitySnapshotCachePath({
|
|
2416
|
+
...(cacheDirectory ? { cacheDirectory } : {})
|
|
2417
|
+
})))) {
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2179
2420
|
throw new Error(`Existing private activity cache is ${existing.code.replaceAll("_", " ")}; ` +
|
|
2180
2421
|
"it was preserved and init stopped. Remove the cache explicitly before rebuilding it.");
|
|
2181
2422
|
}
|
|
2423
|
+
async function isEmptyRealDirectory(path) {
|
|
2424
|
+
try {
|
|
2425
|
+
const info = await lstat(path);
|
|
2426
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
2427
|
+
return false;
|
|
2428
|
+
return (await readdir(path)).length === 0;
|
|
2429
|
+
}
|
|
2430
|
+
catch {
|
|
2431
|
+
return false;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2182
2434
|
async function preserveOrCreateInitRegistry(stateDir, rootPath, asOf, existing) {
|
|
2183
2435
|
if (existing) {
|
|
2184
2436
|
// Leave the valid contract byte-for-byte alone. Rewriting would drop
|
|
@@ -2389,9 +2641,12 @@ function formatInitReceipt(input) {
|
|
|
2389
2641
|
...diagnosticLines,
|
|
2390
2642
|
input.scanError ? ` ! scan failed: ${input.scanError}` : "",
|
|
2391
2643
|
"",
|
|
2392
|
-
`status cache: ${input.cacheStatus} · private local aggregate · nothing uploaded`,
|
|
2644
|
+
`status cache: ${input.cacheStatus} · private local aggregate · ${uploadsNothingLine(input.telemetryDisclosure, "nothing uploaded")}`,
|
|
2393
2645
|
"state: .ai-spend-agent",
|
|
2394
2646
|
"manifest: written last",
|
|
2647
|
+
// Static pointer only, ABOVE the strict single next-command exit line —
|
|
2648
|
+
// init's `next:` line stays last (capture design moments map).
|
|
2649
|
+
signupCopy.initPointer,
|
|
2395
2650
|
"next: npx aibill doctor --sources"
|
|
2396
2651
|
].filter((line) => line !== "").join("\n");
|
|
2397
2652
|
}
|
|
@@ -2844,7 +3099,9 @@ async function listSourcesCommand(args) {
|
|
|
2844
3099
|
// not first-run blockers.
|
|
2845
3100
|
const selfServeProviders = new Set(["openai", "anthropic"]);
|
|
2846
3101
|
const adminUpgradeProviders = {
|
|
2847
|
-
|
|
3102
|
+
// Cursor's Admin API is gated to Enterprise teams (cursor.com/docs/api);
|
|
3103
|
+
// Business/Teams keys are rejected with 401/403.
|
|
3104
|
+
cursor: "requires a Cursor TEAM-ADMIN key (Enterprise teams only)",
|
|
2848
3105
|
"github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
|
|
2849
3106
|
copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
|
|
2850
3107
|
};
|
|
@@ -2866,7 +3123,8 @@ function providerSyncSetupCommand(provider, adminRef) {
|
|
|
2866
3123
|
if (provider === "github-copilot") {
|
|
2867
3124
|
return `npx aibill sync-provider --provider github-copilot --auth-reference ${adminRef} --org <organization>`;
|
|
2868
3125
|
}
|
|
2869
|
-
|
|
3126
|
+
const thirtyDaysAgoUnix = Math.floor(Date.now() / 1_000) - 30 * 24 * 60 * 60;
|
|
3127
|
+
return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time ${thirtyDaysAgoUnix}`;
|
|
2870
3128
|
}
|
|
2871
3129
|
async function connectCommand(args) {
|
|
2872
3130
|
const rootPath = resolve(args.path);
|
|
@@ -2954,6 +3212,10 @@ async function connectCommand(args) {
|
|
|
2954
3212
|
lines.push("");
|
|
2955
3213
|
lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
|
|
2956
3214
|
lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
|
|
3215
|
+
lines.push(" (that start time is 30 days ago; change it to widen the window)");
|
|
3216
|
+
}
|
|
3217
|
+
if (provider === "openai") {
|
|
3218
|
+
lines.push("multi-org: an Admin API key covers ONE organization; repeat the sync with a separate env reference per org (e.g. env:OPENAI_ADMIN_KEY_ORG2) — org totals accumulate");
|
|
2957
3219
|
}
|
|
2958
3220
|
lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
|
|
2959
3221
|
return ok(lines.join("\n"));
|
|
@@ -3064,7 +3326,18 @@ async function syncProviderCommand(args) {
|
|
|
3064
3326
|
enterprise: args.enterprise,
|
|
3065
3327
|
accountId: args.accountId
|
|
3066
3328
|
});
|
|
3067
|
-
|
|
3329
|
+
// Admin credentials are account-scoped (an OpenAI Admin key covers ONE
|
|
3330
|
+
// organization). Each sync belongs to one account slice: different slices
|
|
3331
|
+
// of the same provider accumulate, re-syncing the same slice replaces it,
|
|
3332
|
+
// and unlabeled legacy rows are replaced fail-closed (never double-count).
|
|
3333
|
+
const accountKey = providerAccountKey({
|
|
3334
|
+
provider,
|
|
3335
|
+
authReference: args.authReference,
|
|
3336
|
+
org: args.org,
|
|
3337
|
+
enterprise: args.enterprise,
|
|
3338
|
+
accountId: args.accountId
|
|
3339
|
+
});
|
|
3340
|
+
const syncedRecords = tagProviderAccountRecords(applyProviderContractGate(result.records), accountKey);
|
|
3068
3341
|
const syncedFinancials = summarizeProviderFinancials(syncedRecords);
|
|
3069
3342
|
const syncedCompleteness = providerFinancialCompleteness(syncedRecords, result.coverage);
|
|
3070
3343
|
const syncedSource = createProviderConnection({
|
|
@@ -3076,8 +3349,9 @@ async function syncProviderCommand(args) {
|
|
|
3076
3349
|
completeness: syncedCompleteness,
|
|
3077
3350
|
fetchedAt: new Date(result.fetchedAt)
|
|
3078
3351
|
});
|
|
3352
|
+
const retainedPriorRecords = retainProviderRecordsForNewSync(trustedPrior?.records ?? [], result.provider, accountKey, syncedRecords);
|
|
3079
3353
|
const records = applyProviderContractGate([
|
|
3080
|
-
...
|
|
3354
|
+
...retainedPriorRecords,
|
|
3081
3355
|
...syncedRecords
|
|
3082
3356
|
]).sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
3083
3357
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
@@ -3085,27 +3359,59 @@ async function syncProviderCommand(args) {
|
|
|
3085
3359
|
const headlineRecords = selectProviderFinancialHeadlineRecords(records);
|
|
3086
3360
|
const summary = analyzeSpend(headlineRecords);
|
|
3087
3361
|
const mappings = attributeUsageRecords(records);
|
|
3362
|
+
// Whether prior account slices of THIS provider survived the merge. When
|
|
3363
|
+
// they did, the provider-keyed accounting maps below must stay honest for
|
|
3364
|
+
// the union of slices, not just the slice this run fetched.
|
|
3365
|
+
const retainedSameProviderSlices = retainedPriorRecords.some((record) => record.source.provider === result.provider);
|
|
3088
3366
|
const qaByProvider = {
|
|
3089
3367
|
...trustedAccountingMap(trustedPrior?.accounting, "qaByProvider"),
|
|
3090
3368
|
[result.provider]: result.qa
|
|
3091
3369
|
};
|
|
3370
|
+
const priorProviderCoverage = trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider")[result.provider];
|
|
3371
|
+
// Fail-closed coverage: a provider is complete only when this sync AND
|
|
3372
|
+
// every retained slice were complete. Known ratchet (QA m4): per-slice
|
|
3373
|
+
// coverage is not persisted, so with other slices retained a prior
|
|
3374
|
+
// "partial" sticks even after the offending slice re-syncs complete —
|
|
3375
|
+
// it only UNDER-claims, and recovers when the provider merges with no
|
|
3376
|
+
// other slices retained (single-slice re-sync), after `aibill
|
|
3377
|
+
// drop-slice` removes the stale slice, or after `aibill reset`. The
|
|
3378
|
+
// checkedAt merge below shares the same ratchet and recovery direction.
|
|
3379
|
+
const mergedProviderCoverage = retainedSameProviderSlices && priorProviderCoverage === "partial"
|
|
3380
|
+
? "partial"
|
|
3381
|
+
: result.coverage;
|
|
3092
3382
|
const coverageByProvider = {
|
|
3093
3383
|
...trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider"),
|
|
3094
|
-
[result.provider]:
|
|
3384
|
+
[result.provider]: mergedProviderCoverage
|
|
3095
3385
|
};
|
|
3386
|
+
// Financials span every retained slice of the provider plus this sync —
|
|
3387
|
+
// never just the account this run happened to fetch.
|
|
3096
3388
|
const financialsByProvider = {
|
|
3097
3389
|
...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
|
|
3098
|
-
[result.provider]:
|
|
3390
|
+
[result.provider]: summarizeProviderFinancials(records.filter((record) => record.source.provider === result.provider))
|
|
3099
3391
|
};
|
|
3392
|
+
const priorProviderCheckedAt = trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider")[result.provider];
|
|
3393
|
+
// Freshness stays conservative: a retained slice keeps its older check
|
|
3394
|
+
// time, so an old account slice is never claimed as freshly checked.
|
|
3395
|
+
const mergedProviderCheckedAt = retainedSameProviderSlices &&
|
|
3396
|
+
typeof priorProviderCheckedAt === "string" &&
|
|
3397
|
+
validIsoString(priorProviderCheckedAt) &&
|
|
3398
|
+
priorProviderCheckedAt < result.fetchedAt
|
|
3399
|
+
? priorProviderCheckedAt
|
|
3400
|
+
: result.fetchedAt;
|
|
3100
3401
|
const checkedAtByProvider = {
|
|
3101
3402
|
...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
|
|
3102
|
-
[result.provider]:
|
|
3403
|
+
[result.provider]: mergedProviderCheckedAt
|
|
3103
3404
|
};
|
|
3104
3405
|
const priorCoverageIntervals = trustedAccountingMap(trustedPrior?.accounting, "coverageIntervalsByProvider");
|
|
3105
3406
|
const coverageIntervalsByProvider = Object.fromEntries(Object.entries(priorCoverageIntervals).filter(([provider]) => provider !== result.provider));
|
|
3106
3407
|
const requestedCoverageInterval = result.coverageInterval;
|
|
3107
|
-
|
|
3108
|
-
|
|
3408
|
+
// The provider's claimed window must hold for EVERY retained slice, so it
|
|
3409
|
+
// shrinks to the intersection — and disappears when slices do not overlap.
|
|
3410
|
+
const mergedProviderInterval = retainedSameProviderSlices
|
|
3411
|
+
? intersectProviderCoverageIntervals(priorCoverageIntervals[result.provider], requestedCoverageInterval)
|
|
3412
|
+
: requestedCoverageInterval;
|
|
3413
|
+
if (mergedProviderInterval) {
|
|
3414
|
+
coverageIntervalsByProvider[result.provider] = mergedProviderInterval;
|
|
3109
3415
|
}
|
|
3110
3416
|
// Invalidate any earlier receipt before the first mutation. If a later
|
|
3111
3417
|
// local write fails, the partially updated repository state stays
|
|
@@ -3157,6 +3463,22 @@ async function syncProviderCommand(args) {
|
|
|
3157
3463
|
`financial evidence: ${syncedSource.financialEvidence}`,
|
|
3158
3464
|
`coverage: ${result.coverage}`,
|
|
3159
3465
|
`records fetched: ${result.records.length}`,
|
|
3466
|
+
`account: ${accountKey}`,
|
|
3467
|
+
`provider accounts: ${formatProviderAccountSlices(providerAccountSlices(records, result.provider))}`,
|
|
3468
|
+
// QA M2: billed dollars never disappear without a word — every dropped
|
|
3469
|
+
// prior slice is named with its record count and billed sum.
|
|
3470
|
+
...providerSliceReplacementNotices({
|
|
3471
|
+
provider: result.provider,
|
|
3472
|
+
accountKey,
|
|
3473
|
+
priorRecords: trustedPrior?.records ?? [],
|
|
3474
|
+
retainedRecords: retainedPriorRecords,
|
|
3475
|
+
syncedRecordCount: syncedRecords.length,
|
|
3476
|
+
syncedBilledUsd: syncedFinancials.providerReportedBilledUsd
|
|
3477
|
+
}).map((notice) => `notice: ${notice}`),
|
|
3478
|
+
// QA M1: identical inner record ids across two named slices are the
|
|
3479
|
+
// signature of one organization synced under two references.
|
|
3480
|
+
...duplicateProviderAccountSliceWarnings(records, result.provider)
|
|
3481
|
+
.map((warning) => `warning: ${warning}`),
|
|
3160
3482
|
provider === "cursor"
|
|
3161
3483
|
? "source window: current team subscription cycle returned by Cursor"
|
|
3162
3484
|
: provider === "github-copilot"
|
|
@@ -3185,6 +3507,130 @@ async function syncProviderCommand(args) {
|
|
|
3185
3507
|
};
|
|
3186
3508
|
}
|
|
3187
3509
|
}
|
|
3510
|
+
/**
|
|
3511
|
+
* `aibill drop-slice --provider X --account KEY` — remove one named account
|
|
3512
|
+
* slice from trusted connected state. This is the prune path for the
|
|
3513
|
+
* duplicate-slice diagnostic (one organization synced under two references)
|
|
3514
|
+
* and for a slice whose credential reference was renamed: without it the only
|
|
3515
|
+
* cleanup is a full `aibill reset` plus re-sync of every org. Local-only; no
|
|
3516
|
+
* provider is contacted; the re-signed state can only shrink totals.
|
|
3517
|
+
*/
|
|
3518
|
+
async function dropSliceCommand(args) {
|
|
3519
|
+
const rootPath = resolve(args.path);
|
|
3520
|
+
const requestedProvider = (args.provider ?? "").trim().toLowerCase();
|
|
3521
|
+
const provider = providerAliases[requestedProvider] ?? requestedProvider;
|
|
3522
|
+
const accountKey = (args.account ?? "").trim();
|
|
3523
|
+
if (!provider || !supportedAdminProviders.has(provider) || !accountKey) {
|
|
3524
|
+
return {
|
|
3525
|
+
exitCode: 1,
|
|
3526
|
+
stdout: "",
|
|
3527
|
+
stderr: [
|
|
3528
|
+
"drop-slice requires --provider (openai, anthropic, cursor, github-copilot) and --account <slice key>.",
|
|
3529
|
+
"List the slices first: npx aibill doctor --sources",
|
|
3530
|
+
"example: npx aibill drop-slice --provider openai --account env:OPENAI_ADMIN_KEY_ORG2"
|
|
3531
|
+
].join("\n")
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
|
|
3535
|
+
const persisted = await readPersistedSpend(rootPath);
|
|
3536
|
+
if (!persisted || persisted.mode !== "connected_provider" || persisted.connectedTrust?.trusted !== true) {
|
|
3537
|
+
return {
|
|
3538
|
+
exitCode: 1,
|
|
3539
|
+
stdout: "",
|
|
3540
|
+
stderr: "drop-slice requires trusted connected provider state. Run `npx aibill sync-provider ...` first, or `npx aibill reset` to clear all local state."
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
const droppedRecords = persisted.records.filter((record) => (record.source.provider === provider && record.source.account === accountKey));
|
|
3544
|
+
if (droppedRecords.length === 0) {
|
|
3545
|
+
const slices = providerAccountSlices(persisted.records, provider);
|
|
3546
|
+
return {
|
|
3547
|
+
exitCode: 1,
|
|
3548
|
+
stdout: "",
|
|
3549
|
+
stderr: [
|
|
3550
|
+
`No ${provider} slice matches account "${sanitizeSecretishError(accountKey)}".`,
|
|
3551
|
+
slices.length > 0
|
|
3552
|
+
? `Known ${provider} slices: ${formatProviderAccountSlices(slices)}`
|
|
3553
|
+
: `No ${provider} slices exist in local state.`,
|
|
3554
|
+
"Unlabeled legacy rows cannot be dropped by account; re-syncing the provider replaces them."
|
|
3555
|
+
].join("\n")
|
|
3556
|
+
};
|
|
3557
|
+
}
|
|
3558
|
+
const records = persisted.records.filter((record) => !(record.source.provider === provider && record.source.account === accountKey));
|
|
3559
|
+
const summary = analyzeSpend(selectProviderFinancialHeadlineRecords(records));
|
|
3560
|
+
const mappings = attributeUsageRecords(records);
|
|
3561
|
+
const providerRemaining = records.filter((record) => record.source.provider === provider);
|
|
3562
|
+
// Rebuild the provider-keyed accounting maps. Financials are recomputed
|
|
3563
|
+
// over the remaining slices; coverage/checkedAt/intervals are left as-is —
|
|
3564
|
+
// they can only UNDER-claim after a drop (partial stays partial, windows
|
|
3565
|
+
// stay narrow) and recover on the provider's next sync. A provider with no
|
|
3566
|
+
// remaining records loses its map entries entirely.
|
|
3567
|
+
const accounting = isPlainObject(persisted.accounting)
|
|
3568
|
+
? { ...persisted.accounting }
|
|
3569
|
+
: {};
|
|
3570
|
+
for (const mapName of [
|
|
3571
|
+
"coverageByProvider",
|
|
3572
|
+
"checkedAtByProvider",
|
|
3573
|
+
"coverageIntervalsByProvider",
|
|
3574
|
+
"qaByProvider",
|
|
3575
|
+
"financialsByProvider"
|
|
3576
|
+
]) {
|
|
3577
|
+
const prior = trustedAccountingMap(persisted.accounting, mapName);
|
|
3578
|
+
if (Object.keys(prior).length === 0)
|
|
3579
|
+
continue;
|
|
3580
|
+
const next = { ...prior };
|
|
3581
|
+
if (providerRemaining.length === 0) {
|
|
3582
|
+
delete next[provider];
|
|
3583
|
+
}
|
|
3584
|
+
else if (mapName === "financialsByProvider") {
|
|
3585
|
+
next[provider] = summarizeProviderFinancials(providerRemaining);
|
|
3586
|
+
}
|
|
3587
|
+
if (Object.keys(next).length > 0)
|
|
3588
|
+
accounting[mapName] = next;
|
|
3589
|
+
else
|
|
3590
|
+
delete accounting[mapName];
|
|
3591
|
+
}
|
|
3592
|
+
const droppedFinancials = summarizeProviderFinancials(droppedRecords);
|
|
3593
|
+
const droppedBilled = droppedFinancials.providerReportedBilledUsd;
|
|
3594
|
+
await invalidateConnectedSpendTrustReceipt(rootPath);
|
|
3595
|
+
await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", accounting, persisted.checkedAt ?? new Date().toISOString());
|
|
3596
|
+
// Keep provider-records.json consistent with the receipt-bound spend state
|
|
3597
|
+
// (same records + maps); a missing file is tolerable — spend.json is the
|
|
3598
|
+
// receipt-bound truth.
|
|
3599
|
+
try {
|
|
3600
|
+
const providerFile = await readJson(join(stateDir, "provider-records.json"));
|
|
3601
|
+
await writeJson(join(stateDir, "provider-records.json"), {
|
|
3602
|
+
...providerFile,
|
|
3603
|
+
records,
|
|
3604
|
+
...(accounting.qaByProvider !== undefined ? { qaByProvider: accounting.qaByProvider } : {}),
|
|
3605
|
+
...(accounting.checkedAtByProvider !== undefined ? { checkedAtByProvider: accounting.checkedAtByProvider } : {}),
|
|
3606
|
+
...(accounting.coverageByProvider !== undefined ? { coverageByProvider: accounting.coverageByProvider } : {}),
|
|
3607
|
+
...(accounting.coverageIntervalsByProvider !== undefined
|
|
3608
|
+
? { coverageIntervalsByProvider: accounting.coverageIntervalsByProvider }
|
|
3609
|
+
: {}),
|
|
3610
|
+
...(accounting.financialsByProvider !== undefined ? { financialsByProvider: accounting.financialsByProvider } : {})
|
|
3611
|
+
});
|
|
3612
|
+
}
|
|
3613
|
+
catch {
|
|
3614
|
+
// Diagnostic mirror only; never block the drop on it.
|
|
3615
|
+
}
|
|
3616
|
+
await appendAuditEvent(stateDir, {
|
|
3617
|
+
timestamp: new Date().toISOString(),
|
|
3618
|
+
action: "source_scanned",
|
|
3619
|
+
sourceId: `${provider}-provider-api`,
|
|
3620
|
+
detail: `drop-slice removed the ${provider} account slice "${accountKey}" (${droppedRecords.length} record(s), ${droppedBilled === null ? "no billed evidence" : `billed ${formatOptionalUsd(droppedBilled)}`}). Local state maintenance only; no provider was contacted.`
|
|
3621
|
+
});
|
|
3622
|
+
await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
|
|
3623
|
+
const remainingSlices = providerAccountSlices(records, provider);
|
|
3624
|
+
return ok([
|
|
3625
|
+
"aibill drop-slice",
|
|
3626
|
+
`provider: ${provider}`,
|
|
3627
|
+
`dropped account: ${accountKey}`,
|
|
3628
|
+
`records removed: ${droppedRecords.length} (${droppedBilled === null ? "no billed evidence" : `billed ${formatOptionalUsd(droppedBilled)}`})`,
|
|
3629
|
+
`remaining provider accounts: ${remainingSlices.length > 0 ? formatProviderAccountSlices(remainingSlices) : "none"}`,
|
|
3630
|
+
`combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
|
|
3631
|
+
"note: coverage and freshness labels stay conservative until the provider is re-synced"
|
|
3632
|
+
].join("\n"));
|
|
3633
|
+
}
|
|
3188
3634
|
function formatOptionalUsd(value) {
|
|
3189
3635
|
if (value === null)
|
|
3190
3636
|
return "unavailable";
|
|
@@ -3252,7 +3698,7 @@ async function confirmMappingCommand(args) {
|
|
|
3252
3698
|
`confidence: ${mapping.confidence}`
|
|
3253
3699
|
].join("\n"));
|
|
3254
3700
|
}
|
|
3255
|
-
async function reportCommand(args) {
|
|
3701
|
+
async function reportCommand(args, runtime = {}) {
|
|
3256
3702
|
const rootPath = resolve(args.path);
|
|
3257
3703
|
try {
|
|
3258
3704
|
const sinceDays = args.sinceDays ?? 30;
|
|
@@ -3289,9 +3735,11 @@ async function reportCommand(args) {
|
|
|
3289
3735
|
const reportExperimentProjection = reportableExperiment
|
|
3290
3736
|
? buildActionVerificationProjectionV0(reportableExperiment)
|
|
3291
3737
|
: undefined;
|
|
3738
|
+
const telemetryDisclosure = runtime.telemetryDisclosure === true;
|
|
3292
3739
|
const reportRenderInput = reportableExperiment
|
|
3293
3740
|
? {
|
|
3294
3741
|
...reportInput,
|
|
3742
|
+
telemetryDisclosure,
|
|
3295
3743
|
tokenExperiment: {
|
|
3296
3744
|
id: reportableExperiment.id,
|
|
3297
3745
|
lifecycle: reportableExperiment.lifecycle,
|
|
@@ -3301,7 +3749,7 @@ async function reportCommand(args) {
|
|
|
3301
3749
|
nextCommand: improveRuntimeCommand
|
|
3302
3750
|
}
|
|
3303
3751
|
}
|
|
3304
|
-
: reportInput;
|
|
3752
|
+
: { ...reportInput, telemetryDisclosure };
|
|
3305
3753
|
const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
|
|
3306
3754
|
reportInput.qualitativeCoverage?.status !== "complete";
|
|
3307
3755
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
@@ -3352,7 +3800,9 @@ async function reportCommand(args) {
|
|
|
3352
3800
|
!(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
|
|
3353
3801
|
? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
|
|
3354
3802
|
: `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
|
|
3355
|
-
|
|
3803
|
+
runtime.telemetryDisclosure === true
|
|
3804
|
+
? "privacy: report rendered locally · anonymous command counts shared · aibill telemetry off; only explicit sync-provider contacts the selected provider"
|
|
3805
|
+
: "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
3356
3806
|
"",
|
|
3357
3807
|
"next:",
|
|
3358
3808
|
` open ${htmlPath} view the full report in your browser`,
|
|
@@ -3497,23 +3947,121 @@ async function guardExactProjectRoot(commandName, requestedPath) {
|
|
|
3497
3947
|
].join("\n")
|
|
3498
3948
|
};
|
|
3499
3949
|
}
|
|
3950
|
+
/* ------------------------------------------------------------------ */
|
|
3951
|
+
/* Agent-draft screening copy (AGENT_NATIVE_LOOP_DESIGN.md §5, A1-A11) */
|
|
3952
|
+
/* ------------------------------------------------------------------ */
|
|
3953
|
+
/**
|
|
3954
|
+
* A1 · plan banner, printed once before step 1 when at least one
|
|
3955
|
+
* agent-drafted sentence survived screening. The final line IS the shared
|
|
3956
|
+
* `userSafetyLine` constant, rendered verbatim and unwrapped (n1) so QA 25
|
|
3957
|
+
* can assert byte-identity across the CLI banner, `agentLoop`, and
|
|
3958
|
+
* `draft_improve_command`.
|
|
3959
|
+
*/
|
|
3960
|
+
const agentDraftPlanBanner = [
|
|
3961
|
+
"Your agent helped draft this plan. Nothing is approved yet: review each",
|
|
3962
|
+
"sentence, press Enter to accept it or type your own, and only the APPROVE",
|
|
3963
|
+
"you type at the end authorizes anything.",
|
|
3964
|
+
IMPROVE_USER_SAFETY_LINE_V1
|
|
3965
|
+
].join("\n");
|
|
3966
|
+
/** A4 · whole-draft set-asides. */
|
|
3967
|
+
const agentDraftUnreadableNotice = [
|
|
3968
|
+
"Your agent's draft could not be read (not a valid ab1 draft token).",
|
|
3969
|
+
"Continuing with aibill's own suggestions. Ask your agent to call",
|
|
3970
|
+
"draft_improve_command again and hand you the exact command it returns."
|
|
3971
|
+
].join("\n");
|
|
3972
|
+
const agentDraftStaleNotice = [
|
|
3973
|
+
"Your agent's draft was made for a different test or an older revision of",
|
|
3974
|
+
"this one, so it was set aside. Ask your agent to re-read",
|
|
3975
|
+
"get_token_reduction_test and draft again."
|
|
3976
|
+
].join("\n");
|
|
3977
|
+
const agentDraftNoTestNotice = [
|
|
3978
|
+
"There is no frozen baseline yet, so a drafted plan cannot attach to a test.",
|
|
3979
|
+
"Finish the two start questions first; then ask your agent to draft against",
|
|
3980
|
+
"the new test id."
|
|
3981
|
+
].join("\n");
|
|
3982
|
+
/** A5 · --draft while a plan awaits its record. */
|
|
3983
|
+
const agentDraftAfterApprovalNotice = [
|
|
3984
|
+
"A plan is already approved and waiting for its result, so the draft was",
|
|
3985
|
+
"not used. Record what happened below."
|
|
3986
|
+
].join("\n");
|
|
3987
|
+
/** A7 · record flags with no approved plan. */
|
|
3988
|
+
const recordFlagsNoApprovalNotice = [
|
|
3989
|
+
"No plan is approved yet, so --record-applied-at/--record-canary were not",
|
|
3990
|
+
"used. Approve a plan first."
|
|
3991
|
+
].join("\n");
|
|
3992
|
+
/** A7 · applied-at prefill set aside (reason = exact time-classifier copy). */
|
|
3993
|
+
function recordTimeSetAsideNotice(reason) {
|
|
3994
|
+
return [
|
|
3995
|
+
`Your agent's applied-at time was set aside: ${reason}`,
|
|
3996
|
+
"Answer the question yourself below."
|
|
3997
|
+
].join("\n");
|
|
3998
|
+
}
|
|
3999
|
+
/** A8 · non-interactive run with any draft/record flag. */
|
|
4000
|
+
const agentFlagsNonInteractiveNote = [
|
|
4001
|
+
"Agent drafts and record values only pre-fill the interactive flow; nothing",
|
|
4002
|
+
"was recorded. Run this command in an interactive terminal."
|
|
4003
|
+
].join("\n");
|
|
4004
|
+
/** A10 · verify-flag confusion on improve. */
|
|
4005
|
+
const verifyFlagConfusionNote = [
|
|
4006
|
+
"Note: --applied-at/--canary belong to the advanced verify commands. With",
|
|
4007
|
+
"improve, use --record-applied-at and --record-canary."
|
|
4008
|
+
].join("\n");
|
|
4009
|
+
/** A9 · demo variant banner (binding is checked only in a real run). */
|
|
4010
|
+
const demoDraftBanner = [
|
|
4011
|
+
"DEMO: your agent's draft is used for practice only. Draft binding to a real",
|
|
4012
|
+
"test id and revision is checked only in a real run. Nothing is recorded."
|
|
4013
|
+
].join("\n");
|
|
4014
|
+
/**
|
|
4015
|
+
* Screen a decoded draft with the SAME shared core path the MCP composition
|
|
4016
|
+
* preview uses (`screenAgentDraftSentence`), field by field. A rejected
|
|
4017
|
+
* field falls back to aibill's own suggestion — and to aibill's label; a
|
|
4018
|
+
* surviving field carries `provenance: "agent"` so only genuinely
|
|
4019
|
+
* agent-authored words ever render `Drafted with your agent` (B1, QA 17).
|
|
4020
|
+
*/
|
|
4021
|
+
function screenAgentDraftSentences(draft) {
|
|
4022
|
+
const answers = {};
|
|
4023
|
+
const setAsideNotices = [];
|
|
4024
|
+
let survivors = 0;
|
|
4025
|
+
for (const field of ["change", "rollback", "canary"]) {
|
|
4026
|
+
const verdict = screenAgentDraftSentence(draft[field]);
|
|
4027
|
+
if (verdict.ok) {
|
|
4028
|
+
answers[field] = { value: verdict.value, provenance: "agent" };
|
|
4029
|
+
survivors += 1;
|
|
4030
|
+
}
|
|
4031
|
+
else {
|
|
4032
|
+
// A3 · the reason is the classifier's exact reprompt message; the
|
|
4033
|
+
// credential path's message never contains the rejected text.
|
|
4034
|
+
setAsideNotices.push([
|
|
4035
|
+
`Your agent's ${field} draft was set aside: ${verdict.reason}`,
|
|
4036
|
+
"aibill's own suggestion is shown for that step instead, labeled Suggested."
|
|
4037
|
+
].join("\n"));
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
return { answers, setAsideNotices, survivors };
|
|
4041
|
+
}
|
|
3500
4042
|
/**
|
|
3501
4043
|
* `improve --sample`: the full guided questionnaire against synthetic
|
|
3502
4044
|
* evidence so a new user can practice every step safely. Fail-closed by
|
|
3503
4045
|
* construction: this function never receives a draft store or persistence
|
|
3504
4046
|
* path — no experiment, ownership, approval, draft, or file is written.
|
|
4047
|
+
* With `--draft` (A9) the demo decodes and screens the token exactly like a
|
|
4048
|
+
* real run — practicing the screening is the point — but skips the binding
|
|
4049
|
+
* check, because there is no real test to bind to.
|
|
3505
4050
|
*/
|
|
3506
|
-
async function demoImproveCommand(runtime) {
|
|
4051
|
+
async function demoImproveCommand(args, runtime) {
|
|
3507
4052
|
const demoNext = {
|
|
3508
4053
|
reason: "run the real flow from inside one exact project",
|
|
3509
4054
|
command: improveRuntimeCommand
|
|
3510
4055
|
};
|
|
4056
|
+
const hasAgentFlags = args.agentDraftToken !== undefined ||
|
|
4057
|
+
args.recordAppliedAt !== undefined || args.recordCanary !== undefined;
|
|
3511
4058
|
const guidedIo = runtime.interactive ? await createGuidedIo(runtime) : undefined;
|
|
3512
4059
|
if (!runtime.interactive || !guidedIo) {
|
|
3513
4060
|
return ok(renderCleanExit({
|
|
3514
4061
|
lines: [
|
|
3515
4062
|
"aibill improve · DEMO · synthetic sample — practice run, nothing is recorded",
|
|
3516
|
-
"Demo sample data can never start a token test."
|
|
4063
|
+
"Demo sample data can never start a token test.",
|
|
4064
|
+
...(hasAgentFlags ? [agentFlagsNonInteractiveNote] : [])
|
|
3517
4065
|
],
|
|
3518
4066
|
next: {
|
|
3519
4067
|
reason: "practice the guided token test safely in an interactive terminal",
|
|
@@ -3527,6 +4075,35 @@ async function demoImproveCommand(runtime) {
|
|
|
3527
4075
|
demo: true
|
|
3528
4076
|
};
|
|
3529
4077
|
guidedIo.write("Demo sample data can never start a token test.");
|
|
4078
|
+
// A9 · decode + screen exactly like a real run; binding is not checked.
|
|
4079
|
+
const demoDraft = args.agentDraftToken !== undefined
|
|
4080
|
+
? decodeAgentDraftTokenV1(args.agentDraftToken)
|
|
4081
|
+
: undefined;
|
|
4082
|
+
let demoSuggestedAnswers = {
|
|
4083
|
+
change: {
|
|
4084
|
+
value: "Start with only the files and instructions this task needs.",
|
|
4085
|
+
provenance: "aibill"
|
|
4086
|
+
},
|
|
4087
|
+
rollback: { value: "Restore the prior session workflow.", provenance: "aibill" },
|
|
4088
|
+
canary: {
|
|
4089
|
+
value: "The project tests pass and the requested output is accepted.",
|
|
4090
|
+
provenance: "aibill"
|
|
4091
|
+
}
|
|
4092
|
+
};
|
|
4093
|
+
const demoDraftNotices = [];
|
|
4094
|
+
if (demoDraft !== undefined) {
|
|
4095
|
+
guidedIo.write(demoDraftBanner);
|
|
4096
|
+
if (!demoDraft.ok) {
|
|
4097
|
+
demoDraftNotices.push(agentDraftUnreadableNotice);
|
|
4098
|
+
}
|
|
4099
|
+
else {
|
|
4100
|
+
const screened = screenAgentDraftSentences(demoDraft.draft);
|
|
4101
|
+
if (screened.survivors > 0)
|
|
4102
|
+
demoDraftNotices.push(agentDraftPlanBanner);
|
|
4103
|
+
demoDraftNotices.push(...screened.setAsideNotices);
|
|
4104
|
+
demoSuggestedAnswers = { ...demoSuggestedAnswers, ...screened.answers };
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
3530
4107
|
const demoComplete = (firstLine) => ok(renderCleanExit({
|
|
3531
4108
|
lines: [
|
|
3532
4109
|
firstLine,
|
|
@@ -3543,6 +4120,9 @@ async function demoImproveCommand(runtime) {
|
|
|
3543
4120
|
if (start.action !== "start") {
|
|
3544
4121
|
return demoComplete("DEMO ENDED · nothing was created or stored");
|
|
3545
4122
|
}
|
|
4123
|
+
if (demoDraftNotices.length > 0) {
|
|
4124
|
+
guidedIo.write(demoDraftNotices.join("\n\n"));
|
|
4125
|
+
}
|
|
3546
4126
|
const plan = await runPlanSitting(guidedIo, {
|
|
3547
4127
|
header,
|
|
3548
4128
|
experimentId: "DEMO-tre_v0_0000000000000000",
|
|
@@ -3550,11 +4130,7 @@ async function demoImproveCommand(runtime) {
|
|
|
3550
4130
|
sanitize: (value) => value,
|
|
3551
4131
|
nowIso: () => new Date().toISOString(),
|
|
3552
4132
|
approveExtraLine: "This is a practice approval. It is not recorded and creates no claim.",
|
|
3553
|
-
suggestedAnswers:
|
|
3554
|
-
change: "Start with only the files and instructions this task needs.",
|
|
3555
|
-
rollback: "Restore the prior session workflow.",
|
|
3556
|
-
canary: "The project tests pass and the requested output is accepted."
|
|
3557
|
-
}
|
|
4133
|
+
suggestedAnswers: demoSuggestedAnswers
|
|
3558
4134
|
});
|
|
3559
4135
|
return demoComplete(plan.action === "approved"
|
|
3560
4136
|
? "DEMO COMPLETE · nothing was created or stored"
|
|
@@ -3622,21 +4198,48 @@ function createPlanDraftStore(rootPath) {
|
|
|
3622
4198
|
}
|
|
3623
4199
|
};
|
|
3624
4200
|
}
|
|
4201
|
+
/**
|
|
4202
|
+
* The approved-plan agent handoff (§2e): symmetric with the record leg. The
|
|
4203
|
+
* record command placeholder line lives INSIDE the quoted agent text, not in
|
|
4204
|
+
* a NEXT COMMAND block, so the one-command exit contract is untouched.
|
|
4205
|
+
*/
|
|
3625
4206
|
function improveAgentInstruction(changeSentence) {
|
|
3626
4207
|
return [
|
|
3627
4208
|
`"Execute only the pre-approved reversible plan: ${changeSentence}`,
|
|
3628
|
-
"Make no other optimization, preserve the approved rollback, run the",
|
|
3629
|
-
"approved canary
|
|
3630
|
-
|
|
4209
|
+
"Make no other optimization, preserve the approved rollback, and run the",
|
|
4210
|
+
"approved canary. Then report the exact UTC ISO-8601 time the change was",
|
|
4211
|
+
"applied and whether that exact canary passed or failed — if the canary has",
|
|
4212
|
+
"not run, say so instead. Give the user this one command with the time",
|
|
4213
|
+
"filled in:",
|
|
4214
|
+
` ${actionRuntimeCommand("improve --record-applied-at <time> --record-canary <passed|failed>")}`,
|
|
4215
|
+
"That command only pre-fills the applied-at question; the user types the",
|
|
4216
|
+
'canary answer themselves in their terminal."'
|
|
3631
4217
|
].join("\n");
|
|
3632
4218
|
}
|
|
4219
|
+
/**
|
|
4220
|
+
* m12c: the record-backedOut re-show restates the FINDING label — the exact
|
|
4221
|
+
* approved sentence is unrecoverable by design (hash-only persistence) — and
|
|
4222
|
+
* must say so under the quoted text.
|
|
4223
|
+
*/
|
|
4224
|
+
const improveAgentInstructionHashCaveat = [
|
|
4225
|
+
"(aibill keeps only hashes of your approved sentences; the plan above",
|
|
4226
|
+
"restates the finding, not your exact approved wording.)"
|
|
4227
|
+
].join("\n");
|
|
3633
4228
|
async function improveCommand(args, runtime) {
|
|
3634
4229
|
if (args.sample) {
|
|
3635
|
-
return demoImproveCommand(runtime);
|
|
4230
|
+
return demoImproveCommand(args, runtime);
|
|
3636
4231
|
}
|
|
3637
4232
|
const rootGuard = await guardExactProjectRoot("improve", args.path);
|
|
3638
4233
|
if (rootGuard)
|
|
3639
4234
|
return rootGuard;
|
|
4235
|
+
// §2c dispatch inputs. Decoding never throws; every set-aside prints one
|
|
4236
|
+
// notice and CONTINUES — a bad draft never ends the run (P0B principle 2).
|
|
4237
|
+
let agentDraft = args.agentDraftToken !== undefined
|
|
4238
|
+
? decodeAgentDraftTokenV1(args.agentDraftToken)
|
|
4239
|
+
: undefined;
|
|
4240
|
+
const hasRecordFlags = args.recordAppliedAt !== undefined || args.recordCanary !== undefined;
|
|
4241
|
+
const hasAgentFlags = agentDraft !== undefined || hasRecordFlags;
|
|
4242
|
+
let recordFlagsNoticed = false;
|
|
3640
4243
|
const sinceDays = args.sinceDays ?? 30;
|
|
3641
4244
|
if (!validSinceDays(sinceDays))
|
|
3642
4245
|
return invalidSinceDaysResult();
|
|
@@ -3712,14 +4315,39 @@ async function improveCommand(args, runtime) {
|
|
|
3712
4315
|
});
|
|
3713
4316
|
const guidedIo = runtime.interactive ? await createGuidedIo(runtime) : undefined;
|
|
3714
4317
|
if (!runtime.interactive || !guidedIo) {
|
|
4318
|
+
// A8 · drafts and record values are prefills for the interactive flow
|
|
4319
|
+
// only; the read-only render must say nothing was recorded.
|
|
4320
|
+
const readOnlyNote = "No experiment, approval, or project state changed. The private local evidence cache may refresh. Run this command in an interactive terminal to start or record a test." +
|
|
4321
|
+
(hasAgentFlags ? `\n${agentFlagsNonInteractiveNote}` : "");
|
|
3715
4322
|
return ok(renderImproveExperience(model, {
|
|
3716
|
-
note:
|
|
4323
|
+
note: readOnlyNote,
|
|
3717
4324
|
...(improveProjectLine ? { projectLine: improveProjectLine } : {})
|
|
3718
4325
|
}));
|
|
3719
4326
|
}
|
|
3720
4327
|
const commandTitle = "aibill improve · one reversible token test";
|
|
3721
4328
|
const experimentTag = (id) => id ? `test ${id.slice(0, 15)}` : "test: none yet";
|
|
4329
|
+
// A10 · the verify flags do not belong to improve; say so and ignore.
|
|
4330
|
+
if (args.appliedAt !== undefined || args.canary !== undefined) {
|
|
4331
|
+
guidedIo.write(verifyFlagConfusionNote);
|
|
4332
|
+
}
|
|
4333
|
+
// Phases past plan/record (collecting, rollback, terminal): the flags
|
|
4334
|
+
// have no question to pre-fill; say so once instead of failing.
|
|
4335
|
+
if (hasAgentFlags &&
|
|
4336
|
+
model.phase !== "start" && model.phase !== "awaiting_intervention") {
|
|
4337
|
+
guidedIo.write("The current step needs no draft or record values, so they were not used.");
|
|
4338
|
+
}
|
|
3722
4339
|
if (model.phase === "start" && !preferred) {
|
|
4340
|
+
// §2c dispatch 2: a plan draft cannot bind before the freeze — the
|
|
4341
|
+
// experimentId is created at freeze. The draft is set aside NOW; after
|
|
4342
|
+
// the freeze the plan sitting uses aibill's own suggestions.
|
|
4343
|
+
if (agentDraft !== undefined) {
|
|
4344
|
+
guidedIo.write(agentDraftNoTestNotice);
|
|
4345
|
+
agentDraft = undefined;
|
|
4346
|
+
}
|
|
4347
|
+
if (hasRecordFlags && !recordFlagsNoticed) {
|
|
4348
|
+
guidedIo.write(recordFlagsNoApprovalNotice);
|
|
4349
|
+
recordFlagsNoticed = true;
|
|
4350
|
+
}
|
|
3723
4351
|
const start = await runStartSitting(guidedIo, {
|
|
3724
4352
|
header: { commandTitle, experimentLabel: experimentTag() },
|
|
3725
4353
|
findingLabel: model.oneChange.label,
|
|
@@ -3786,6 +4414,54 @@ async function improveCommand(args, runtime) {
|
|
|
3786
4414
|
}
|
|
3787
4415
|
: undefined;
|
|
3788
4416
|
const draftStore = createPlanDraftStore(rootPath);
|
|
4417
|
+
// §2c dispatch 3: record flags cannot apply before an approval.
|
|
4418
|
+
if (hasRecordFlags && !recordFlagsNoticed) {
|
|
4419
|
+
guidedIo.write(recordFlagsNoApprovalNotice);
|
|
4420
|
+
recordFlagsNoticed = true;
|
|
4421
|
+
}
|
|
4422
|
+
// The machine drafts; the human approves. aibill already knows the
|
|
4423
|
+
// change it found — never make the user re-type a worse version.
|
|
4424
|
+
// Every fallback field carries aibill's OWN provenance so it can
|
|
4425
|
+
// never render under the agent label (B1).
|
|
4426
|
+
const aibillSuggestions = {
|
|
4427
|
+
change: {
|
|
4428
|
+
value: model.oneChange.label.endsWith(".")
|
|
4429
|
+
? model.oneChange.label
|
|
4430
|
+
: `${model.oneChange.label}.`,
|
|
4431
|
+
provenance: "aibill"
|
|
4432
|
+
},
|
|
4433
|
+
rollback: {
|
|
4434
|
+
value: "Restore the prior session workflow.",
|
|
4435
|
+
provenance: "aibill"
|
|
4436
|
+
},
|
|
4437
|
+
canary: {
|
|
4438
|
+
value: "The project tests pass and the requested output is accepted.",
|
|
4439
|
+
provenance: "aibill"
|
|
4440
|
+
}
|
|
4441
|
+
};
|
|
4442
|
+
let suggestedAnswers = aibillSuggestions;
|
|
4443
|
+
const draftNotices = [];
|
|
4444
|
+
if (agentDraft !== undefined) {
|
|
4445
|
+
if (!agentDraft.ok) {
|
|
4446
|
+
draftNotices.push(agentDraftUnreadableNotice);
|
|
4447
|
+
}
|
|
4448
|
+
else if (agentDraft.draft.experimentId !== preferred.id ||
|
|
4449
|
+
agentDraft.draft.revisionId !== preferred.revisionId) {
|
|
4450
|
+
// Stale revision, wrong test, or a token replayed from another
|
|
4451
|
+
// project — the id simply does not match this project's test.
|
|
4452
|
+
draftNotices.push(agentDraftStaleNotice);
|
|
4453
|
+
}
|
|
4454
|
+
else {
|
|
4455
|
+
const screened = screenAgentDraftSentences(agentDraft.draft);
|
|
4456
|
+
if (screened.survivors > 0)
|
|
4457
|
+
draftNotices.push(agentDraftPlanBanner);
|
|
4458
|
+
draftNotices.push(...screened.setAsideNotices);
|
|
4459
|
+
suggestedAnswers = { ...aibillSuggestions, ...screened.answers };
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
if (draftNotices.length > 0) {
|
|
4463
|
+
guidedIo.write(draftNotices.join("\n\n"));
|
|
4464
|
+
}
|
|
3789
4465
|
const plan = await runPlanSitting(guidedIo, {
|
|
3790
4466
|
header,
|
|
3791
4467
|
experimentId: preferred.id,
|
|
@@ -3794,15 +4470,7 @@ async function improveCommand(args, runtime) {
|
|
|
3794
4470
|
draftStore,
|
|
3795
4471
|
sanitize: sanitizeLocalActivityText,
|
|
3796
4472
|
nowIso: () => new Date().toISOString(),
|
|
3797
|
-
|
|
3798
|
-
// change it found — never make the user re-type a worse version.
|
|
3799
|
-
suggestedAnswers: {
|
|
3800
|
-
change: model.oneChange.label.endsWith(".")
|
|
3801
|
-
? model.oneChange.label
|
|
3802
|
-
: `${model.oneChange.label}.`,
|
|
3803
|
-
rollback: "Restore the prior session workflow.",
|
|
3804
|
-
canary: "The project tests pass and the requested output is accepted."
|
|
3805
|
-
}
|
|
4473
|
+
suggestedAnswers
|
|
3806
4474
|
});
|
|
3807
4475
|
const rerunNext = { reason: "run this again to continue the plan", command: improveRuntimeCommand };
|
|
3808
4476
|
if (plan.action === "cancelled" || plan.action === "backedOut") {
|
|
@@ -3912,10 +4580,40 @@ async function improveCommand(args, runtime) {
|
|
|
3912
4580
|
const approvedByLine = accountabilityState.ownership
|
|
3913
4581
|
? `Approved ${approvedPlan.approvedAt} by ${accountabilityState.ownership.displayLabels.humanOwner} (${accountabilityState.ownership.approverRole.roleLabel})`
|
|
3914
4582
|
: `Approved ${approvedPlan.approvedAt}`;
|
|
4583
|
+
// §2c dispatch 4: a plan is pre-approved — the draft (if any) is set
|
|
4584
|
+
// aside (A5); the applied-at prefill is pre-screened with the FULL
|
|
4585
|
+
// time classifier (approvedAtIso context) so before-approval/future
|
|
4586
|
+
// values are set aside honestly NOW instead of at Enter (A7); the
|
|
4587
|
+
// canary report NEVER prefills — claim line only, typed p/f/n (M6).
|
|
4588
|
+
const recordNotices = [];
|
|
4589
|
+
if (agentDraft !== undefined) {
|
|
4590
|
+
recordNotices.push(agentDraftAfterApprovalNotice);
|
|
4591
|
+
}
|
|
4592
|
+
let suggestedRecord;
|
|
4593
|
+
if (args.recordAppliedAt !== undefined) {
|
|
4594
|
+
const timeVerdict = classifyGuidedAnswer("time", args.recordAppliedAt, {
|
|
4595
|
+
approvedAtIso: approvedPlan.approvedAt
|
|
4596
|
+
});
|
|
4597
|
+
if (timeVerdict.outcome === "accept") {
|
|
4598
|
+
suggestedRecord = { appliedAtIso: args.recordAppliedAt };
|
|
4599
|
+
}
|
|
4600
|
+
else {
|
|
4601
|
+
recordNotices.push(recordTimeSetAsideNotice(timeVerdict.outcome === "reject"
|
|
4602
|
+
? timeVerdict.message
|
|
4603
|
+
: "the time could not be read"));
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
if (recordNotices.length > 0) {
|
|
4607
|
+
guidedIo.write(recordNotices.join("\n\n"));
|
|
4608
|
+
}
|
|
3915
4609
|
const record = await runRecordSitting(guidedIo, {
|
|
3916
4610
|
header,
|
|
3917
4611
|
approvedAtIso: approvedPlan.approvedAt,
|
|
3918
|
-
approvedByLine
|
|
4612
|
+
approvedByLine,
|
|
4613
|
+
...(suggestedRecord !== undefined ? { suggested: suggestedRecord } : {}),
|
|
4614
|
+
...(args.recordCanary !== undefined
|
|
4615
|
+
? { agentCanaryReport: args.recordCanary }
|
|
4616
|
+
: {})
|
|
3919
4617
|
});
|
|
3920
4618
|
if (record.action === "cancelled") {
|
|
3921
4619
|
return ok(renderCleanExit({
|
|
@@ -3927,9 +4625,12 @@ async function improveCommand(args, runtime) {
|
|
|
3927
4625
|
}));
|
|
3928
4626
|
}
|
|
3929
4627
|
if (record.action === "backedOut") {
|
|
4628
|
+
// m12c: this path restates the FINDING label, not the approved
|
|
4629
|
+
// sentence (hash-only persistence) — the caveat says so, indented
|
|
4630
|
+
// inside the quoted block by renderForYourAgent.
|
|
3930
4631
|
return ok(renderCleanExit({
|
|
3931
4632
|
lines: [`${experimentTag(preferred.id)} · waiting for the applied change`],
|
|
3932
|
-
extraBlocks: [renderForYourAgent(improveAgentInstruction(model.oneChange.label))],
|
|
4633
|
+
extraBlocks: [renderForYourAgent(`${improveAgentInstruction(model.oneChange.label)}\n${improveAgentInstructionHashCaveat}`)],
|
|
3933
4634
|
next: { reason: "after the agent reports back, run exactly this", command: improveRuntimeCommand }
|
|
3934
4635
|
}));
|
|
3935
4636
|
}
|
|
@@ -4652,7 +5353,8 @@ async function applyArtifactCommand(args) {
|
|
|
4652
5353
|
experiment.lifecycle !== "invalidated"));
|
|
4653
5354
|
if (active) {
|
|
4654
5355
|
return tokenVerificationResult(active, false, [
|
|
4655
|
-
"An active token test already owns this project; Apply handed off to it and generated no conflicting candidate or artifacts."
|
|
5356
|
+
"An active token test already owns this project; Apply handed off to it and generated no conflicting candidate or artifacts.",
|
|
5357
|
+
`Use your agent normally on this project, then: ${improveRuntimeCommand}`
|
|
4656
5358
|
]);
|
|
4657
5359
|
}
|
|
4658
5360
|
}
|
|
@@ -5219,13 +5921,17 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
|
|
|
5219
5921
|
freshLocalCalls = freshActionLogs.calls;
|
|
5220
5922
|
freshCodexInvocationFiles = freshActionLogs.codexInvocationFiles;
|
|
5221
5923
|
}
|
|
5924
|
+
// Financial and qualitative readers have separate truth contracts. Always
|
|
5925
|
+
// read the bounded/streaming financial axis once: local mode uses it as the
|
|
5926
|
+
// headline, while connected mode carries it into the saved report as a
|
|
5927
|
+
// separate API-equivalent comparison that is never added to billed cost.
|
|
5928
|
+
const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
|
|
5929
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
5930
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
5931
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
5932
|
+
sinceIso
|
|
5933
|
+
}).catch(() => undefined);
|
|
5222
5934
|
if (needsFreshLogs) {
|
|
5223
|
-
const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
|
|
5224
|
-
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
5225
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
5226
|
-
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
5227
|
-
sinceIso
|
|
5228
|
-
}).catch(() => undefined);
|
|
5229
5935
|
if (freshFinancialLogs && freshFinancialLogs.records.length > 0) {
|
|
5230
5936
|
const records = freshFinancialLogs.records;
|
|
5231
5937
|
const summary = analyzeSpend(records);
|
|
@@ -5368,6 +6074,9 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
|
|
|
5368
6074
|
allRecords: spendState.mode === "connected_provider"
|
|
5369
6075
|
? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
|
|
5370
6076
|
: spendState.records ?? [],
|
|
6077
|
+
...(spendState.mode === "connected_provider" && (freshFinancialLogs?.records.length ?? 0) > 0
|
|
6078
|
+
? { localFinancialRecords: freshFinancialLogs.records }
|
|
6079
|
+
: {}),
|
|
5371
6080
|
dataMode: spendState.mode,
|
|
5372
6081
|
discovery,
|
|
5373
6082
|
mappings: mappings ?? [],
|
|
@@ -5452,6 +6161,12 @@ function parseArgs(argv) {
|
|
|
5452
6161
|
parsed.provider = rest[0];
|
|
5453
6162
|
rest.shift();
|
|
5454
6163
|
}
|
|
6164
|
+
if (command === "signup" && rest[0] && !rest[0].startsWith("-")) {
|
|
6165
|
+
parsed.signupEmail = rest.shift();
|
|
6166
|
+
}
|
|
6167
|
+
if (command === "telemetry" && rest[0] && !rest[0].startsWith("--")) {
|
|
6168
|
+
parsed.telemetryAction = rest.shift();
|
|
6169
|
+
}
|
|
5455
6170
|
if (command === "verify") {
|
|
5456
6171
|
const first = rest[0];
|
|
5457
6172
|
if (first === "inspect" || first === "start" || first === "mark-applied" ||
|
|
@@ -5478,14 +6193,27 @@ function parseArgs(argv) {
|
|
|
5478
6193
|
"--source-path", "--type", "--provider", "--source-id", "--team",
|
|
5479
6194
|
"--person", "--client", "--cost-center", "--role", "--project", "--agent", "--workflow",
|
|
5480
6195
|
"--evidence", "--confidence", "--label", "--auth-reference",
|
|
5481
|
-
"--start-time", "--end-time", "--org", "--enterprise", "--account-id",
|
|
6196
|
+
"--start-time", "--end-time", "--org", "--enterprise", "--account-id", "--account",
|
|
5482
6197
|
"--interval", "--cycles", "--canary", "--quality", "--change-digest",
|
|
5483
6198
|
"--rollback-digest", "--canary-digest", "--approved-at", "--applied-at",
|
|
5484
|
-
"--pr", "--business-outcome"
|
|
6199
|
+
"--pr", "--business-outcome",
|
|
6200
|
+
"--draft", "--record-applied-at", "--record-canary",
|
|
6201
|
+
"--ref"
|
|
5485
6202
|
]);
|
|
5486
6203
|
const numericValueFlags = new Set([
|
|
5487
6204
|
"--since-days", "--confidence", "--start-time", "--end-time", "--interval", "--cycles", "--pr"
|
|
5488
6205
|
]);
|
|
6206
|
+
// A repeated --draft/--record-* flag means the pasted line was assembled
|
|
6207
|
+
// from two commands: a parse error, never a silent last-wins (§2b, m11).
|
|
6208
|
+
const seenOnceOnlyFlags = new Set();
|
|
6209
|
+
const onceOnly = (flag) => {
|
|
6210
|
+
if (seenOnceOnlyFlags.has(flag)) {
|
|
6211
|
+
parsed.parseErrors.push(`${flag} may appear once`);
|
|
6212
|
+
return false;
|
|
6213
|
+
}
|
|
6214
|
+
seenOnceOnlyFlags.add(flag);
|
|
6215
|
+
return true;
|
|
6216
|
+
};
|
|
5489
6217
|
for (let index = 0; index < rest.length; index += 1) {
|
|
5490
6218
|
const arg = rest[index];
|
|
5491
6219
|
const nextValue = rest[index + 1];
|
|
@@ -5558,6 +6286,51 @@ function parseArgs(argv) {
|
|
|
5558
6286
|
}
|
|
5559
6287
|
continue;
|
|
5560
6288
|
}
|
|
6289
|
+
if (arg === "--draft") {
|
|
6290
|
+
const next = rest[index + 1];
|
|
6291
|
+
if (next) {
|
|
6292
|
+
if (onceOnly("--draft")) {
|
|
6293
|
+
if (looksLikeAgentDraftToken(next)) {
|
|
6294
|
+
parsed.agentDraftToken = next;
|
|
6295
|
+
}
|
|
6296
|
+
else {
|
|
6297
|
+
parsed.parseErrors.push("--draft must be the single ab1.… token from draft_improve_command; do not hand-build or quote it");
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6300
|
+
index += 1;
|
|
6301
|
+
}
|
|
6302
|
+
continue;
|
|
6303
|
+
}
|
|
6304
|
+
if (arg === "--record-applied-at") {
|
|
6305
|
+
const next = rest[index + 1];
|
|
6306
|
+
if (next) {
|
|
6307
|
+
if (onceOnly("--record-applied-at")) {
|
|
6308
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z$/.test(next)) {
|
|
6309
|
+
parsed.recordAppliedAt = next;
|
|
6310
|
+
}
|
|
6311
|
+
else {
|
|
6312
|
+
parsed.parseErrors.push("--record-applied-at must be a UTC Z time, e.g. 2026-08-18T09:12:00Z");
|
|
6313
|
+
}
|
|
6314
|
+
}
|
|
6315
|
+
index += 1;
|
|
6316
|
+
}
|
|
6317
|
+
continue;
|
|
6318
|
+
}
|
|
6319
|
+
if (arg === "--record-canary") {
|
|
6320
|
+
const next = rest[index + 1];
|
|
6321
|
+
if (next) {
|
|
6322
|
+
if (onceOnly("--record-canary")) {
|
|
6323
|
+
if (next === "passed" || next === "failed") {
|
|
6324
|
+
parsed.recordCanary = next;
|
|
6325
|
+
}
|
|
6326
|
+
else {
|
|
6327
|
+
parsed.parseErrors.push("--record-canary must be passed or failed");
|
|
6328
|
+
}
|
|
6329
|
+
}
|
|
6330
|
+
index += 1;
|
|
6331
|
+
}
|
|
6332
|
+
continue;
|
|
6333
|
+
}
|
|
5561
6334
|
if (arg === "--change-digest" || arg === "--rollback-digest" ||
|
|
5562
6335
|
arg === "--canary-digest") {
|
|
5563
6336
|
const next = rest[index + 1];
|
|
@@ -5576,6 +6349,30 @@ function parseArgs(argv) {
|
|
|
5576
6349
|
parsed.ignoreState = true;
|
|
5577
6350
|
continue;
|
|
5578
6351
|
}
|
|
6352
|
+
if (arg === "--forget") {
|
|
6353
|
+
parsed.signupForget = true;
|
|
6354
|
+
continue;
|
|
6355
|
+
}
|
|
6356
|
+
if (arg === "--never") {
|
|
6357
|
+
parsed.signupNever = true;
|
|
6358
|
+
continue;
|
|
6359
|
+
}
|
|
6360
|
+
if (arg === "--ref") {
|
|
6361
|
+
const next = rest[index + 1];
|
|
6362
|
+
if (next) {
|
|
6363
|
+
// Allowlist sanitization happens here so nothing outside
|
|
6364
|
+
// [a-z0-9-]{1,24} can ever reach the payload builder (QA 8).
|
|
6365
|
+
const tag = sanitizeSignupRefTag(next);
|
|
6366
|
+
if (tag === undefined) {
|
|
6367
|
+
parsed.parseErrors.push("--ref must be 1-24 lowercase letters, digits, or dashes (e.g. --ref starfund)");
|
|
6368
|
+
}
|
|
6369
|
+
else {
|
|
6370
|
+
parsed.signupRef = tag;
|
|
6371
|
+
}
|
|
6372
|
+
index += 1;
|
|
6373
|
+
}
|
|
6374
|
+
continue;
|
|
6375
|
+
}
|
|
5579
6376
|
if (arg === "--plan") {
|
|
5580
6377
|
const next = rest[index + 1];
|
|
5581
6378
|
if (next) {
|
|
@@ -5808,6 +6605,14 @@ function parseArgs(argv) {
|
|
|
5808
6605
|
}
|
|
5809
6606
|
continue;
|
|
5810
6607
|
}
|
|
6608
|
+
if (arg === "--account") {
|
|
6609
|
+
const next = rest[index + 1];
|
|
6610
|
+
if (next) {
|
|
6611
|
+
parsed.account = next;
|
|
6612
|
+
index += 1;
|
|
6613
|
+
}
|
|
6614
|
+
continue;
|
|
6615
|
+
}
|
|
5811
6616
|
if (arg === "--account-id") {
|
|
5812
6617
|
const next = rest[index + 1];
|
|
5813
6618
|
if (next) {
|
|
@@ -5862,8 +6667,13 @@ function parseArgs(argv) {
|
|
|
5862
6667
|
}
|
|
5863
6668
|
continue;
|
|
5864
6669
|
}
|
|
6670
|
+
// A11 (copy polish on the existing unknown-flag rejection): quoted
|
|
6671
|
+
// per-sentence draft flags never existed — point at the one-token design.
|
|
6672
|
+
const draftFlagHint = arg === "--draft-change" || arg === "--draft-rollback" || arg === "--draft-canary"
|
|
6673
|
+
? " — agent drafts travel as one --draft token from draft_improve_command, not as quoted sentences"
|
|
6674
|
+
: "";
|
|
5865
6675
|
parsed.parseErrors.push(arg.startsWith("-")
|
|
5866
|
-
? `unknown flag "${arg}"`
|
|
6676
|
+
? `unknown flag "${arg}"${draftFlagHint}`
|
|
5867
6677
|
: `unexpected argument "${arg}"`);
|
|
5868
6678
|
}
|
|
5869
6679
|
return parsed;
|
|
@@ -5987,7 +6797,7 @@ function isNodeError(error, code) {
|
|
|
5987
6797
|
function ok(stdout) {
|
|
5988
6798
|
return { exitCode: 0, stdout, stderr: "" };
|
|
5989
6799
|
}
|
|
5990
|
-
function helpText() {
|
|
6800
|
+
function helpText(telemetryDisclosure) {
|
|
5991
6801
|
return [
|
|
5992
6802
|
"aibill — your AI cost and usage evidence in one private view",
|
|
5993
6803
|
"",
|
|
@@ -5997,11 +6807,11 @@ function helpText() {
|
|
|
5997
6807
|
" npx aibill --sample Show the clearly labeled illustrative demo (never implicit)",
|
|
5998
6808
|
" npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
|
|
5999
6809
|
" npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
|
|
6000
|
-
` ${improveRuntimeCommand}
|
|
6001
|
-
` ${actionRuntimeCommand("index")}
|
|
6002
|
-
` ${actionRuntimeCommand("identify")}
|
|
6003
|
-
` ${actionRuntimeCommand("outcome github")}
|
|
6004
|
-
` ${actionRuntimeCommand("accountability")}
|
|
6810
|
+
` ${improveRuntimeCommand} Test one personalized change and measure whether token usage fell`,
|
|
6811
|
+
` ${actionRuntimeCommand("index")} Read very large agent histories to completion so results stop saying "indexing"`,
|
|
6812
|
+
` ${actionRuntimeCommand("identify")} Confirm the human owner, team, client/cost center, and approval role`,
|
|
6813
|
+
` ${actionRuntimeCommand("outcome github")} Attach one merged PR whose observed status checks passed`,
|
|
6814
|
+
` ${actionRuntimeCommand("accountability")} Answer owner → outcome → approval → measured-result for this project`,
|
|
6005
6815
|
"",
|
|
6006
6816
|
"Add official provider-reported cost (ADMIN/owner-gated):",
|
|
6007
6817
|
" npx aibill connect openai Requires an org-owner Admin credential reference",
|
|
@@ -6024,6 +6834,9 @@ function helpText() {
|
|
|
6024
6834
|
" [--replace] Explicitly replace an existing statusLine while preserving it for uninstall",
|
|
6025
6835
|
" statusline uninstall Remove only the owned setting and restore its preserved predecessor",
|
|
6026
6836
|
" statusline expand Print every subscription with committed price, runways, and 7d API-equivalent",
|
|
6837
|
+
" signup <email> [--ref <token>] Join the launch list · email only · the exact payload is shown before send",
|
|
6838
|
+
" [--never] Never ask again (nothing is sent) [--forget] Clear local signup state",
|
|
6839
|
+
" telemetry [on|off] Show anonymous command-count status + the exact last payload · switch it",
|
|
6027
6840
|
" doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
|
|
6028
6841
|
" reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
|
|
6029
6842
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
@@ -6057,7 +6870,9 @@ function helpText() {
|
|
|
6057
6870
|
"Cron (production watch): add a crontab entry such as:",
|
|
6058
6871
|
" 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
|
|
6059
6872
|
"",
|
|
6060
|
-
|
|
6873
|
+
telemetryDisclosure === true
|
|
6874
|
+
? "Privacy: local analysis and reports upload nothing; anonymous command counts shared · aibill telemetry off. Only explicit sync-provider contacts the selected provider through an env: reference."
|
|
6875
|
+
: "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
|
|
6061
6876
|
"aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
|
|
6062
6877
|
].join("\n");
|
|
6063
6878
|
}
|
|
@@ -6093,10 +6908,40 @@ export async function runMain() {
|
|
|
6093
6908
|
const argv = process.argv.slice(2);
|
|
6094
6909
|
const command = argv[0];
|
|
6095
6910
|
const isInstantDemo = !command || command === "quickstart" || command === "demo";
|
|
6096
|
-
|
|
6097
|
-
|
|
6911
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6912
|
+
const startedAtMs = Date.now();
|
|
6913
|
+
// Bin-entry-only telemetry (notice-before-first-byte; see telemetry.ts).
|
|
6914
|
+
// Embedded runCli callers and the MCP server never construct this, so
|
|
6915
|
+
// they can never emit an event.
|
|
6916
|
+
let telemetry;
|
|
6917
|
+
try {
|
|
6918
|
+
const { openCliTelemetry } = await import("./telemetry.js");
|
|
6919
|
+
telemetry = await openCliTelemetry();
|
|
6920
|
+
}
|
|
6921
|
+
catch {
|
|
6922
|
+
// Telemetry must never break the CLI.
|
|
6923
|
+
}
|
|
6924
|
+
// The during-scan signup ask (capture design, placement addendum
|
|
6925
|
+
// 2026-08-24): on a qualifying interactive first run the ask fills the
|
|
6926
|
+
// scan wait — it prints its own wait line, so the spinner stays off. A
|
|
6927
|
+
// run that does not qualify (or whose signup state disallows the ask)
|
|
6928
|
+
// takes the exact fast path below, byte-identical.
|
|
6929
|
+
let preAsk;
|
|
6930
|
+
if (interactive && !process.env.CI && !process.env.AI_SPEND_NO_PROMPT) {
|
|
6931
|
+
try {
|
|
6932
|
+
const signup = await import("./signup.js");
|
|
6933
|
+
if (signup.qualifiesForPreReceiptSignupAsk(argv)) {
|
|
6934
|
+
preAsk = await signup.openPreReceiptSignupAskInTerminal();
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
catch {
|
|
6938
|
+
// The ask must never break the receipt path.
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
// Show a spinner only for the work-heavy instant-demo path, only on a
|
|
6942
|
+
// real TTY so piped output stays clean, and never over the open ask.
|
|
6098
6943
|
let spinner;
|
|
6099
|
-
if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
|
|
6944
|
+
if (isInstantDemo && !preAsk && process.stdout.isTTY && !process.env.NO_COLOR) {
|
|
6100
6945
|
try {
|
|
6101
6946
|
const { default: yoctoSpinner } = await import("yocto-spinner");
|
|
6102
6947
|
spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
|
|
@@ -6106,13 +6951,14 @@ export async function runMain() {
|
|
|
6106
6951
|
}
|
|
6107
6952
|
}
|
|
6108
6953
|
let result;
|
|
6954
|
+
let askOutcome = { kind: "no_ask" };
|
|
6109
6955
|
let promptInterface;
|
|
6110
6956
|
let guidedInterface;
|
|
6111
6957
|
try {
|
|
6112
|
-
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6113
6958
|
let guidedIoShared;
|
|
6114
|
-
|
|
6959
|
+
const runPipeline = () => runCli(argv, {
|
|
6115
6960
|
interactive,
|
|
6961
|
+
...(telemetry?.disclosureActive === true ? { telemetryDisclosure: true } : {}),
|
|
6116
6962
|
...(interactive ? {
|
|
6117
6963
|
prompt: async (question) => {
|
|
6118
6964
|
if (!promptInterface) {
|
|
@@ -6141,6 +6987,21 @@ export async function runMain() {
|
|
|
6141
6987
|
}
|
|
6142
6988
|
} : {})
|
|
6143
6989
|
});
|
|
6990
|
+
if (preAsk) {
|
|
6991
|
+
// Receipt renders only when BOTH the human's answer (bounded by the
|
|
6992
|
+
// ask's own timeouts) and the pipeline have resolved; a pipeline
|
|
6993
|
+
// error still waits for the bounded ask, then re-throws into the
|
|
6994
|
+
// error voice below — never a hung prompt over a dead pipeline.
|
|
6995
|
+
const { orchestratePreReceiptAsk } = await import("./signup.js");
|
|
6996
|
+
const orchestrated = await orchestratePreReceiptAsk({ session: preAsk.session, runPipeline });
|
|
6997
|
+
askOutcome = orchestrated.outcome;
|
|
6998
|
+
if (!orchestrated.pipeline.ok)
|
|
6999
|
+
throw orchestrated.pipeline.error;
|
|
7000
|
+
result = orchestrated.pipeline.value;
|
|
7001
|
+
}
|
|
7002
|
+
else {
|
|
7003
|
+
result = await runPipeline();
|
|
7004
|
+
}
|
|
6144
7005
|
}
|
|
6145
7006
|
catch (error) {
|
|
6146
7007
|
// The product's error voice, never a raw stack trace — and never an
|
|
@@ -6151,7 +7012,7 @@ export async function runMain() {
|
|
|
6151
7012
|
stdout: "",
|
|
6152
7013
|
stderr: [
|
|
6153
7014
|
`aibill hit an unexpected error: ${message}`,
|
|
6154
|
-
"Nothing was uploaded. The command stopped without completing; run diagnostics before retrying
|
|
7015
|
+
`${telemetry?.disclosureActive === true ? telemetryDisclosureLine : "Nothing was uploaded."} The command stopped without completing; run diagnostics before retrying.`,
|
|
6155
7016
|
"Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
|
|
6156
7017
|
].join("\n")
|
|
6157
7018
|
};
|
|
@@ -6161,6 +7022,11 @@ export async function runMain() {
|
|
|
6161
7022
|
guidedInterface?.close();
|
|
6162
7023
|
spinner?.stop();
|
|
6163
7024
|
}
|
|
7025
|
+
// A read that ended without the user pressing Enter (timeout / Ctrl-C)
|
|
7026
|
+
// left the prompt line open; start the receipt on a fresh line.
|
|
7027
|
+
if (preAsk?.needsFreshLine()) {
|
|
7028
|
+
process.stdout.write("\n");
|
|
7029
|
+
}
|
|
6164
7030
|
if (result.stdout) {
|
|
6165
7031
|
console.log(result.stdout);
|
|
6166
7032
|
}
|
|
@@ -6168,6 +7034,29 @@ export async function runMain() {
|
|
|
6168
7034
|
console.error(result.stderr);
|
|
6169
7035
|
}
|
|
6170
7036
|
process.exitCode = result.exitCode;
|
|
7037
|
+
// Consent renders strictly AFTER the receipt: scope line, the literal
|
|
7038
|
+
// payload JSON, a typed y, ONE POST. Nothing here can change the
|
|
7039
|
+
// receipt's bytes or exit code.
|
|
7040
|
+
if (preAsk && askOutcome.kind === "email") {
|
|
7041
|
+
await preAsk.runConsent(askOutcome);
|
|
7042
|
+
}
|
|
7043
|
+
preAsk?.close();
|
|
7044
|
+
// Telemetry LAST: on the first interactive run this prints the one-time
|
|
7045
|
+
// notice (and stamps it); on later noticed runs it fires ONE
|
|
7046
|
+
// fire-and-forget event with a hard 1500ms abort. Never blocks the
|
|
7047
|
+
// receipt, never changes the exit code.
|
|
7048
|
+
try {
|
|
7049
|
+
await telemetry?.finish({
|
|
7050
|
+
argv,
|
|
7051
|
+
ok: result.exitCode === 0,
|
|
7052
|
+
durationMs: Date.now() - startedAtMs,
|
|
7053
|
+
interactive,
|
|
7054
|
+
version: await cliVersion()
|
|
7055
|
+
});
|
|
7056
|
+
}
|
|
7057
|
+
catch {
|
|
7058
|
+
// Telemetry must never break the CLI.
|
|
7059
|
+
}
|
|
6171
7060
|
}
|
|
6172
7061
|
if (invokedAsMain) {
|
|
6173
7062
|
await runMain();
|