@agent-finops/core 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +5 -3
  2. package/dist/actionPlanner.d.ts +140 -0
  3. package/dist/actionPlanner.js +938 -0
  4. package/dist/actionVerification.d.ts +1240 -0
  5. package/dist/actionVerification.js +1028 -0
  6. package/dist/activitySnapshot.d.ts +142 -50
  7. package/dist/activitySnapshot.js +145 -6
  8. package/dist/activitySnapshotCache.d.ts +8 -1
  9. package/dist/activitySnapshotCache.js +103 -7
  10. package/dist/agentDraftToken.d.ts +80 -0
  11. package/dist/agentDraftToken.js +188 -0
  12. package/dist/agentEconomicsReceipt.d.ts +74 -74
  13. package/dist/agentLoopContract.d.ts +27 -0
  14. package/dist/agentLoopContract.js +36 -0
  15. package/dist/glance.d.ts +27 -1
  16. package/dist/glance.js +151 -12
  17. package/dist/guidedAnswer.d.ts +51 -0
  18. package/dist/guidedAnswer.js +352 -0
  19. package/dist/index.d.ts +14 -2
  20. package/dist/index.js +13 -1
  21. package/dist/localAgentFormats/gemini.js +2 -2
  22. package/dist/localAgentFormats/registry.js +6 -2
  23. package/dist/localAgentFormats/runtimeRegistry.js +5 -2
  24. package/dist/localAgentFormats/types.d.ts +2 -1
  25. package/dist/localAgentLogs.d.ts +362 -3
  26. package/dist/localAgentLogs.js +1964 -165
  27. package/dist/modelPricing.d.ts +1 -1
  28. package/dist/modelPricing.js +1 -1
  29. package/dist/projectEconomics.d.ts +617 -0
  30. package/dist/projectEconomics.js +620 -0
  31. package/dist/projectEconomicsBuilder.d.ts +89 -0
  32. package/dist/projectEconomicsBuilder.js +473 -0
  33. package/dist/projectIndexStore.d.ts +545 -0
  34. package/dist/projectIndexStore.js +606 -0
  35. package/dist/providerConnectors.d.ts +161 -1
  36. package/dist/providerConnectors.js +406 -11
  37. package/dist/qualitativeIndexCache.d.ts +494 -0
  38. package/dist/qualitativeIndexCache.js +930 -0
  39. package/dist/resultCard.d.ts +350 -0
  40. package/dist/resultCard.js +604 -0
  41. package/dist/runtimeCommands.d.ts +36 -0
  42. package/dist/runtimeCommands.js +50 -0
  43. package/dist/scanGuard.d.ts +3 -1
  44. package/dist/scanGuard.js +164 -4
  45. package/dist/schema.d.ts +33 -31
  46. package/dist/schema.js +9 -1
  47. package/dist/sessionVitals.d.ts +145 -0
  48. package/dist/sessionVitals.js +521 -0
  49. package/dist/toolInvocations.d.ts +40 -1
  50. package/dist/toolInvocations.js +101 -20
  51. package/package.json +1 -1
package/dist/glance.js CHANGED
@@ -3,6 +3,7 @@ import { canPriceTokenUsageAtScope, estimateTokenCostUsd, PRICING_TABLE_AS_OF }
3
3
  import { subscriptionPlans } from "./planMath.js";
4
4
  import { buildContextHealth } from "./contextHealth.js";
5
5
  import { localAgentFormatDescriptors, localAgentFormatSupports } from "./localAgentFormats/registry.js";
6
+ import { aibillImproveCommandV0 } from "./runtimeCommands.js";
6
7
  const HOUR_MS = 60 * 60 * 1_000;
7
8
  const DAY_MS = 24 * HOUR_MS;
8
9
  /**
@@ -55,17 +56,37 @@ export function buildUsageGlance(calls, options = {}) {
55
56
  const focusSessions = latest?.project && !isGenericProject(latest.project)
56
57
  ? windowSessions.filter((session) => session.project === latest.project)
57
58
  : windowSessions;
58
- const focus = buildMainFocus(focusSessions, focusWindowDays, now);
59
- const sessionHealth = suppliedContextHealth ?? buildContextHealth({ calls: safeCalls, now });
60
- const anomaly = anomalyFromContextHealth(sessionHealth);
61
- const primaryAction = buildPrimaryAction({
62
- currentSession,
63
- focus,
64
- limits,
65
- sessionHealth,
66
- generatedAt: now.toISOString(),
67
- filesParsed: options.filesParsed ?? 0
68
- });
59
+ const qualitativeCoverage = options.qualitativeCoverage ?? {
60
+ status: "complete",
61
+ selectedFiles: options.filesParsed ?? 0,
62
+ readCompletely: options.filesParsed ?? 0,
63
+ skippedForBudget: 0
64
+ };
65
+ const qualitativeComplete = qualitativeCoverage.status === "complete";
66
+ const focus = qualitativeComplete
67
+ ? buildMainFocus(focusSessions, focusWindowDays, now)
68
+ : null;
69
+ const baseSessionHealth = suppliedContextHealth ?? buildContextHealth({ calls: safeCalls, now });
70
+ const sessionHealth = {
71
+ ...baseSessionHealth,
72
+ qualitativeCoverage
73
+ };
74
+ const anomaly = qualitativeComplete ? anomalyFromContextHealth(sessionHealth) : null;
75
+ const primaryAction = qualitativeComplete
76
+ ? buildPrimaryAction({
77
+ currentSession,
78
+ focus,
79
+ limits,
80
+ sessionHealth,
81
+ generatedAt: now.toISOString(),
82
+ filesParsed: options.filesParsed ?? 0
83
+ })
84
+ : buildCoverageLimitedPrimaryAction({
85
+ currentSession,
86
+ sessionHealth,
87
+ coverage: qualitativeCoverage
88
+ });
89
+ const tokenExperiment = sanitizeActionVerificationProjection(options.actionVerificationProjection);
69
90
  const detectedAgents = (options.detectedAgents ?? uniqueAgents(safeCalls))
70
91
  .filter((agent) => localAgentFormatSupports(agent, "glance"));
71
92
  const agentsWithCurrentLimits = new Set(limits.filter((limit) => limit.freshness === "current").map((limit) => limit.agent));
@@ -81,14 +102,19 @@ export function buildUsageGlance(calls, options = {}) {
81
102
  "A five-hour percentage is current for at most five hours after observation; a weekly percentage is current for at most 24 hours. Older transcript evidence is labeled stale and never drives an action.",
82
103
  "Main focus is a local summary of observed human prompts and tool activity, not elapsed time or spend; raw prompts are not returned.",
83
104
  "The primary action combines Context Health, Main focus, and reported runway locally. It only provides a copyable handoff prompt and never runs an agent automatically.",
105
+ "A token-test percentage compares matched local session cohorts guarded by explicit quality evidence; it is not certified savings, verified outcome ROI, or a provider bill.",
84
106
  "Claude Code transcripts do not report plan headroom. Missing limits remain unavailable instead of being inferred.",
85
- "Cursor and GitHub Copilot require their provider connections because their local chat stores are not treated as authoritative billing transcripts."
107
+ "Cursor and GitHub Copilot require their provider connections because their local chat stores are not treated as authoritative billing transcripts.",
108
+ ...(qualitativeComplete ? [] : [
109
+ "Main focus, anomaly, and context-change handoff are unavailable because the bounded qualitative index is incomplete; no global driver was inferred from a selected subset."
110
+ ])
86
111
  ];
87
112
  return {
88
113
  dataMode: "local_transcripts",
89
114
  generatedAt: now.toISOString(),
90
115
  coverage: {
91
116
  filesParsed: options.filesParsed ?? 0,
117
+ qualitative: qualitativeCoverage,
92
118
  supportedTranscriptAgents: supportedFormats.map((descriptor) => descriptor.id),
93
119
  detectedAgents,
94
120
  rateLimitMetadata: supportedFormats.map((descriptor) => ({
@@ -146,6 +172,14 @@ export function buildUsageGlance(calls, options = {}) {
146
172
  execution: "copy_prompt",
147
173
  automaticExecution: false
148
174
  },
175
+ tokenExperiment: {
176
+ source: tokenExperiment
177
+ ? "canonical_action_verification_projection"
178
+ : "not_available",
179
+ calculation: "core_experiment_evaluator",
180
+ cohort: "matched_local_sessions",
181
+ automaticExecution: false
182
+ },
149
183
  network: {
150
184
  uploaded: false
151
185
  }
@@ -157,9 +191,114 @@ export function buildUsageGlance(calls, options = {}) {
157
191
  anomaly,
158
192
  sessionHealth,
159
193
  primaryAction,
194
+ ...(tokenExperiment ? { tokenExperiment } : {}),
160
195
  caveats
161
196
  };
162
197
  }
198
+ function buildCoverageLimitedPrimaryAction(input) {
199
+ const project = safeActionMetadata(input.currentSession?.project, 80);
200
+ const status = input.coverage.status === "partial" ? "partial" : "not available";
201
+ return {
202
+ kind: "session_handoff",
203
+ intent: "inspect_current_work",
204
+ label: project ? `Refresh evidence · ${project}` : "Refresh evidence",
205
+ detail: `Main focus unavailable · qualitative index ${status}`,
206
+ ...(project ? { project } : {}),
207
+ agentPrompt: [
208
+ "aibill's bounded qualitative evidence is incomplete.",
209
+ "Do not infer a global main focus, waste cause, or context change from the selected subset.",
210
+ `Run \`${aibillImproveCommandV0()}\` from the exact project root to refresh the private index, then review the new evidence before editing.`
211
+ ].join("\n"),
212
+ source: "context_health_focus_and_reported_runway",
213
+ confidence: "low",
214
+ execution: "copy_prompt",
215
+ requiresUserConfirmation: true,
216
+ evidenceWindowDays: input.sessionHealth.deadContext.windowDays
217
+ };
218
+ }
219
+ const actionVerificationStates = new Set([
220
+ "collect_baseline",
221
+ "approve_one_change",
222
+ "collect_post_change",
223
+ "review_measured_result",
224
+ "rollback",
225
+ "resolve_evidence",
226
+ "rolled_back",
227
+ "cancelled"
228
+ ]);
229
+ const actionVerificationTones = new Set([
230
+ "neutral",
231
+ "attention",
232
+ "positive",
233
+ "negative"
234
+ ]);
235
+ const actionVerificationEvidenceLabels = new Set([
236
+ "calculated",
237
+ "missing"
238
+ ]);
239
+ const actionVerificationQualityLabels = new Set([
240
+ "held",
241
+ "regressed",
242
+ "insufficient"
243
+ ]);
244
+ const actionVerificationQualityEvidence = new Set([
245
+ "verified",
246
+ "observed",
247
+ "user_declared",
248
+ "missing"
249
+ ]);
250
+ const experimentIdPattern = /^tre_v0_[a-f0-9]{64}$/;
251
+ const findingIdPattern = /^wf_v0_[a-f0-9]{64}$/;
252
+ const candidateKeyPattern = /^wfc_v0_[a-f0-9]{64}$/;
253
+ /**
254
+ * Treat the optional adapter input as untrusted at runtime. A malformed or
255
+ * internally inconsistent projection is omitted instead of becoming a stale
256
+ * or invented Glance claim. This function deliberately does not derive any
257
+ * experiment result.
258
+ */
259
+ function sanitizeActionVerificationProjection(input) {
260
+ if (!input || typeof input !== "object")
261
+ return undefined;
262
+ const safe = sanitizeStringMetadata(input);
263
+ if (safe.schemaVersion !== 0 ||
264
+ !experimentIdPattern.test(safe.experimentId) ||
265
+ !findingIdPattern.test(safe.findingId) ||
266
+ !candidateKeyPattern.test(safe.candidateKey) ||
267
+ !actionVerificationStates.has(safe.state) ||
268
+ !actionVerificationTones.has(safe.tone) ||
269
+ !actionVerificationEvidenceLabels.has(safe.evidenceLabel) ||
270
+ !actionVerificationQualityLabels.has(safe.qualityLabel) ||
271
+ !actionVerificationQualityEvidence.has(safe.qualityEvidence) ||
272
+ !isSafeExperimentCount(safe.baselineSessions) ||
273
+ !isSafeExperimentCount(safe.postChangeSessions) ||
274
+ !isSafeExperimentCount(safe.minimumSessions) ||
275
+ safe.minimumSessions < 1 ||
276
+ (safe.reductionPercent !== null && (!Number.isFinite(safe.reductionPercent) ||
277
+ safe.reductionPercent > 100 ||
278
+ safe.reductionPercent < -1_000_000))) {
279
+ return undefined;
280
+ }
281
+ const measured = safe.state === "review_measured_result";
282
+ const claimQualityEvidence = safe.qualityEvidence === "verified" ||
283
+ safe.qualityEvidence === "observed" ||
284
+ safe.qualityEvidence === "user_declared";
285
+ if (safe.reductionPercent !== null) {
286
+ const claimEvidenceIsComplete = safe.evidenceLabel === "calculated" &&
287
+ safe.qualityLabel === "held" &&
288
+ claimQualityEvidence;
289
+ const signMatchesState = (measured && safe.reductionPercent >= 0) ||
290
+ (safe.state === "rollback" && safe.reductionPercent < 0);
291
+ if (!claimEvidenceIsComplete || !signMatchesState)
292
+ return undefined;
293
+ }
294
+ else if (measured) {
295
+ return undefined;
296
+ }
297
+ return safe;
298
+ }
299
+ function isSafeExperimentCount(value) {
300
+ return Number.isSafeInteger(value) && value >= 0;
301
+ }
163
302
  function toGlancePlan(agent, detectedPlans) {
164
303
  const detected = detectedPlans.find((plan) => plan.agent === agent);
165
304
  if (!detected)
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared guided-answer classifier for the aibill improve/identify flows.
3
+ *
4
+ * One function, three consumers: the CLI terminal prompts (typed input and
5
+ * Enter-kept prefills), the CLI agent-draft screening lane, and the MCP
6
+ * `draft_improve_command` preview. Keeping a single pure module in core is
7
+ * what makes the "same classifier at composition and at Enter-accept"
8
+ * invariant true by construction rather than by parity discipline.
9
+ *
10
+ * Design: P0B_FLOW_DESIGN.md §2/§3b/§3c and AGENT_NATIVE_LOOP_DESIGN.md §2f
11
+ * (moved here from packages/cli/src/guidedPrompt.ts, verbatim; the path and
12
+ * credential predicates moved with it from projectAccountabilityState.ts so
13
+ * the floor relationship can never drift).
14
+ */
15
+ /**
16
+ * Exported as the classification FLOOR for the guided-prompt engine: the
17
+ * per-prompt classifier must reject at least everything these predicates
18
+ * reject, so `parseDisplayLabel` can never abort on an answer the prompt
19
+ * already accepted (the Aug 17 incident class).
20
+ */
21
+ export declare function isPathLike(value: string): boolean;
22
+ export declare function isCredentialLike(value: string): boolean;
23
+ export type GuidedFieldKind = "prose" | "name" | "team" | "role" | "optional" | "time" | "choice" | "approve";
24
+ export type ClassifyContext = {
25
+ example?: string;
26
+ /** Exact accepted tokens for choice fields, lowercase. */
27
+ choiceTokens?: readonly string[];
28
+ /** ISO instant the plan was approved (time fields). */
29
+ approvedAtIso?: string;
30
+ /** Clock override for tests. */
31
+ nowMs?: number;
32
+ /** Consecutive identical shell-rejections at this step (keep override). */
33
+ priorShellRejections?: number;
34
+ };
35
+ export type ClassifyResult = {
36
+ outcome: "accept";
37
+ value: string;
38
+ } | {
39
+ outcome: "navigate";
40
+ action: "back" | "cancel";
41
+ } | {
42
+ outcome: "skip";
43
+ } | {
44
+ outcome: "reject";
45
+ code: RejectCode;
46
+ message: string;
47
+ };
48
+ export type RejectCode = "control" | "empty" | "shell" | "path" | "credential" | "reserved" | "timestamp_shaped" | "length" | "substance" | "time_invalid" | "time_before_approval" | "time_future" | "choice" | "approve_case";
49
+ export declare function looksLikeCredential(answer: string): boolean;
50
+ export declare function classifyGuidedAnswer(kind: GuidedFieldKind, rawInput: string, context?: ClassifyContext): ClassifyResult;
51
+ //# sourceMappingURL=guidedAnswer.d.ts.map
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Shared guided-answer classifier for the aibill improve/identify flows.
3
+ *
4
+ * One function, three consumers: the CLI terminal prompts (typed input and
5
+ * Enter-kept prefills), the CLI agent-draft screening lane, and the MCP
6
+ * `draft_improve_command` preview. Keeping a single pure module in core is
7
+ * what makes the "same classifier at composition and at Enter-accept"
8
+ * invariant true by construction rather than by parity discipline.
9
+ *
10
+ * Design: P0B_FLOW_DESIGN.md §2/§3b/§3c and AGENT_NATIVE_LOOP_DESIGN.md §2f
11
+ * (moved here from packages/cli/src/guidedPrompt.ts, verbatim; the path and
12
+ * credential predicates moved with it from projectAccountabilityState.ts so
13
+ * the floor relationship can never drift).
14
+ */
15
+ /* ------------------------------------------------------------------ */
16
+ /* Floor predicates (accountability backstop) */
17
+ /* ------------------------------------------------------------------ */
18
+ /**
19
+ * Exported as the classification FLOOR for the guided-prompt engine: the
20
+ * per-prompt classifier must reject at least everything these predicates
21
+ * reject, so `parseDisplayLabel` can never abort on an answer the prompt
22
+ * already accepted (the Aug 17 incident class).
23
+ */
24
+ export function isPathLike(value) {
25
+ return /^(?:~?[\\/]|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/]|\\\\)/u.test(value) ||
26
+ /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value) || value.includes("\\");
27
+ }
28
+ export function isCredentialLike(value) {
29
+ return /(?:sk-(?:ant-|proj-)?|sk_|gh[pousr]_|github_pat_|npm_|AIza|xox[baprs]-|glpat-|AKIA)[A-Za-z0-9_-]{8,}/i
30
+ .test(value) ||
31
+ /(?:api[_ -]?key|access[_ -]?token|auth[_ -]?token|secret|password)\s*[:=]\s*\S+/i
32
+ .test(value);
33
+ }
34
+ const unambiguousBinaries = new Set([
35
+ "node", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", "tsx", "ts-node",
36
+ "git", "gh", "curl", "wget", "bash", "sh", "zsh", "fish", "pwsh", "powershell",
37
+ "python", "python3", "pip", "pip3", "pipx", "uv", "uvx", "poetry", "pytest",
38
+ "vitest", "jest", "brew", "apt", "docker", "kubectl", "cargo", "rustc",
39
+ "dotnet", "mvn", "gradle", "terraform", "ssh", "scp", "rsync", "sudo",
40
+ "chmod", "chown", "xargs", "grep", "rg", "sed", "awk", "tar", "zip", "unzip",
41
+ "aibill", "ai-spend-agent", "vim", "nano", "code", "dir", "del", "robocopy"
42
+ ]);
43
+ /** Common English verbs that are also binaries: reject only with corroboration. */
44
+ const ambiguousVerbs = new Set([
45
+ "make", "open", "find", "go", "date", "kill", "top", "head", "tail", "touch",
46
+ "export", "source", "alias", "echo", "type", "cd", "ls", "cat", "cp", "mv",
47
+ "rm", "printf", "less", "ps", "copy", "move"
48
+ ]);
49
+ const reservedVocabulary = new Set([
50
+ "held", "passed", "failed", "missing", "regressed", "approve", "approved",
51
+ "yes", "no", "y", "n", "p", "f", "h", "r", "m", "now", "skip", "keep"
52
+ ]);
53
+ /**
54
+ * The accountability backstop predicate (`isCredentialLike`) is the FLOOR:
55
+ * this classifier must reject at least everything `parseDisplayLabel` would
56
+ * reject, or a validated answer could still abort later (B2 QA blocker B1).
57
+ * These extra patterns sit on top of the floor.
58
+ */
59
+ const extraCredentialPattern = /(authorization\s*:\s*(?:bearer|basic)\s+\S+|-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[ _-]?key|token|password|secret)\s*[:=]\s*\S+)/i;
60
+ export function looksLikeCredential(answer) {
61
+ return isCredentialLike(answer) || extraCredentialPattern.test(answer);
62
+ }
63
+ const pathExtensionPattern = /\S+\.(?:js|ts|mjs|cjs|jsx|tsx|json|sh|bash|py|rb|go|rs|md|yml|yaml|toml|lock|xml|ps1|bat|cmd|gradle)(?:$|\s)/i;
64
+ /** Strict full date-time with zone, mirroring the CLI's validIsoString. */
65
+ const strictIsoPattern = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:[Zz]|[+-]\d{2}:?\d{2})$/;
66
+ const futureToleranceMs = 2 * 60 * 1000;
67
+ /**
68
+ * Exact non-answer phrases (normalized: lowercase, punctuation stripped).
69
+ * A plan sentence must be actionable later; "i am not sure" passes the
70
+ * two-word substance bar but records a safety net that cannot be executed.
71
+ * Exact match only — a real sentence that merely CONTAINS one still passes.
72
+ */
73
+ const nonAnswerPhrases = new Set([
74
+ "i am not sure", "im not sure", "not sure", "unsure", "i am unsure",
75
+ "idk", "i dont know", "dont know", "no idea", "dunno",
76
+ "no se", "no sé", "ni idea",
77
+ "whatever", "anything", "nothing", "none", "na", "tbd",
78
+ "help", "test", "testing", "asdf"
79
+ ]);
80
+ function isNonAnswer(lowered) {
81
+ const normalized = lowered.replace(/[.,!?'’]/g, "").replace(/\s+/g, " ").trim();
82
+ return nonAnswerPhrases.has(normalized);
83
+ }
84
+ const choiceWordAliases = {
85
+ yes: "y",
86
+ no: "n",
87
+ passed: "p",
88
+ failed: "f",
89
+ held: "h",
90
+ regressed: "r",
91
+ missing: "m"
92
+ };
93
+ function stripQuotes(token) {
94
+ return token.replace(/^["'`]+/, "").replace(/["'`]+$/, "");
95
+ }
96
+ function stripEnvPrefixes(tokens) {
97
+ let index = 0;
98
+ while (index < tokens.length &&
99
+ (/^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(tokens[index]) ||
100
+ tokens[index] === "sudo" || tokens[index] === "env")) {
101
+ index += 1;
102
+ }
103
+ return tokens.slice(index);
104
+ }
105
+ /**
106
+ * §2f rule 2 (AGENT_NATIVE_LOOP_DESIGN.md): a pipe straight into an
107
+ * interpreter, spaced or not — `|sh`, `curl …|bash`, `|python3 -c …`.
108
+ */
109
+ const pipeToInterpreterPattern = /\|\s*(?:sh|bash|zsh|fish|dash|node|python3?|perl|ruby|pwsh)\b/i;
110
+ /** §2f rule 4: free-standing multi-letter short flag (` -rf`), corroboration only. */
111
+ const multiLetterShortFlagPattern = /(?:^|\s)-[A-Za-z]{2,}(?=\s|$)/;
112
+ function looksLikeShellCommand(answer) {
113
+ if (/^[$%#>] ?/.test(answer))
114
+ return true;
115
+ if (/&&|\|\||`|\$\(|>>|<<|2>&1| \| | > | < /.test(answer))
116
+ return true;
117
+ if (pipeToInterpreterPattern.test(answer))
118
+ return true;
119
+ if (/(?:^|\s)--[A-Za-z][\w-]*/.test(answer))
120
+ return true;
121
+ if (/(?:^|\s)-[A-Za-z](?=\s|$)/.test(answer))
122
+ return true;
123
+ // PowerShell Verb-Noun cmdlet shape (Get-ChildItem, Remove-Item …).
124
+ if (/^[A-Z][a-z]+-[A-Z][A-Za-z]+(?:\s|$)/.test(answer))
125
+ return true;
126
+ // §2f rule 1: strip any leading run of whitespace/quote/chain sigils
127
+ // before first-token analysis (covers `"; rm …`, `'; …`, `| sh`, `& …`
128
+ // fragments). If the strip removed a `;`, `|`, or `&`, the text STARTS
129
+ // like a command chain — that is a reject on its own. A plain leading
130
+ // quotation mark followed by words strips harmlessly and never rejects.
131
+ const leadingSigils = /^[\s"'`;|&]+/.exec(answer)?.[0] ?? "";
132
+ if (/[;|&]/.test(leadingSigils))
133
+ return true;
134
+ const body = answer.slice(leadingSigils.length);
135
+ const tokens = stripEnvPrefixes(body.split(/\s+/).filter(Boolean));
136
+ // Second signals, shared by first-token ambiguity and §2f rule 3: a
137
+ // path-like token, a file extension, or a multi-letter short flag
138
+ // (`rm -rf …` — rule 4; needs whitespace before the `-`, so hyphenated
139
+ // words like `well-known` or `re-use` can never match).
140
+ const hasPathToken = tokens.some((token) => /^(?:\/|\.\/|\.\.\/|~\/)/.test(token) || token.includes("/"));
141
+ const hasExtension = pathExtensionPattern.test(body);
142
+ const hasShortFlag = multiLetterShortFlagPattern.test(body);
143
+ const first = stripQuotes(tokens[0] ?? "").toLowerCase();
144
+ if (unambiguousBinaries.has(first))
145
+ return true;
146
+ if (ambiguousVerbs.has(first)) {
147
+ // Ambiguous English verbs reject only with a second signal: a path-like
148
+ // token, a file extension, a corroborating flag, or a terse
149
+ // all-lowercase fragment that reads like a command line, not a sentence.
150
+ const terseLowercase = tokens.length <= 2 && body === body.toLowerCase();
151
+ if (hasPathToken || hasExtension || hasShortFlag || terseLowercase)
152
+ return true;
153
+ }
154
+ // §2f rule 3: `;` chained command starts. A `;` followed by an unambiguous
155
+ // binary rejects outright; followed by an ambiguous verb it rejects only
156
+ // with a second signal — English semicolons ("…workflow; keep the earlier
157
+ // settings", "…change; go back to the prior flow") survive.
158
+ for (const chained of body.matchAll(/;\s*([^\s;|&]+)/g)) {
159
+ const chainedFirst = stripQuotes(chained[1]).toLowerCase();
160
+ if (unambiguousBinaries.has(chainedFirst))
161
+ return true;
162
+ if (ambiguousVerbs.has(chainedFirst) &&
163
+ (hasPathToken || hasExtension || hasShortFlag)) {
164
+ return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+ function looksLikePath(answer, kind) {
170
+ // The accountability backstop predicate is the floor: it covers leading
171
+ // slashes and dot-segments, drive letters, backslashes, and bare ".".
172
+ if (isPathLike(answer))
173
+ return true;
174
+ if (kind === "prose") {
175
+ if (pathExtensionPattern.test(answer))
176
+ return true;
177
+ const slashTokens = answer.split(/\s+/).filter((token) => token.includes("/"));
178
+ return slashTokens.length >= 2;
179
+ }
180
+ // Name-like fields: a slashed token is a path; a bare file-extension token
181
+ // counts only when it IS the whole answer — "Node.js Guild" is a team.
182
+ if (/\S+\/\S+/.test(answer))
183
+ return true;
184
+ return pathExtensionPattern.test(answer) && !/\s/.test(answer.trim());
185
+ }
186
+ function byteLength(value) {
187
+ return Buffer.byteLength(value.normalize("NFC"), "utf8");
188
+ }
189
+ function hasControlOrFormatCharacters(value) {
190
+ // C0/C1 controls including embedded newlines and DEL (tab and trailing
191
+ // CR are normalized earlier), plus the invisible/directional format
192
+ // characters that can spoof what the review screen appears to say.
193
+ // ZWNJ/ZWJ (U+200C/U+200D) are deliberately ALLOWED: they are standard
194
+ // orthography in Persian and other scripts and in emoji families.
195
+ if (/[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u2028\u2029]/.test(value))
196
+ return true;
197
+ return /[\u00AD\u061C\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/.test(value);
198
+ }
199
+ function hasUnpairedSurrogate(value) {
200
+ for (let index = 0; index < value.length; index += 1) {
201
+ const code = value.charCodeAt(index);
202
+ if (code >= 0xd800 && code <= 0xdbff) {
203
+ const next = value.charCodeAt(index + 1);
204
+ if (!(next >= 0xdc00 && next <= 0xdfff))
205
+ return true;
206
+ index += 1;
207
+ }
208
+ else if (code >= 0xdc00 && code <= 0xdfff) {
209
+ return true;
210
+ }
211
+ }
212
+ return false;
213
+ }
214
+ export function classifyGuidedAnswer(kind, rawInput, context = {}) {
215
+ const normalizedTabs = rawInput.replace(/\t/g, " ").replace(/\r$/, "");
216
+ const answer = normalizedTabs.trim();
217
+ const lowered = answer.toLowerCase();
218
+ const reject = (code, message) => ({
219
+ outcome: "reject", code, message
220
+ });
221
+ const exampleSuffix = context.example ? ` e.g. ${context.example}` : "";
222
+ // Navigation pre-pass (every field).
223
+ if (lowered === "back" || lowered === "b")
224
+ return { outcome: "navigate", action: "back" };
225
+ if (lowered === "cancel" || lowered === "q" || lowered === "quit" || lowered === "exit") {
226
+ return { outcome: "navigate", action: "cancel" };
227
+ }
228
+ if (kind === "optional" && (answer === "" || lowered === "skip")) {
229
+ return { outcome: "skip" };
230
+ }
231
+ if (hasControlOrFormatCharacters(answer) || hasUnpairedSurrogate(answer)) {
232
+ return reject("control", "That answer carried hidden control characters (usually a stray paste). Type it as plain text.");
233
+ }
234
+ if (answer === "") {
235
+ if (kind === "approve") {
236
+ // An empty answer at the approval screen is a decline, not an error.
237
+ return { outcome: "navigate", action: "cancel" };
238
+ }
239
+ return reject("empty", "This step needs an answer in words. Type it, or type back or cancel.");
240
+ }
241
+ if (looksLikeCredential(answer)) {
242
+ // Never echo, never store: callers must discard this input entirely.
243
+ return reject("credential", "That looks like it contains a credential. aibill never stores credentials — that answer was discarded. Type it again without the secret.");
244
+ }
245
+ switch (kind) {
246
+ case "approve": {
247
+ if (answer === "APPROVE")
248
+ return { outcome: "accept", value: answer };
249
+ // Clear approval intent gets the nudge, never a silent decline:
250
+ // APPROVED, aprove, i approve, full-width IME APPROVE, yes/y.
251
+ const folded = answer.normalize("NFKC").toUpperCase();
252
+ if (folded.includes("APPROV") || folded.includes("APROVE") ||
253
+ lowered === "yes" || lowered === "y") {
254
+ return reject("approve_case", "Approval must be typed APPROVE, in capitals, so it cannot happen by accident.");
255
+ }
256
+ // Any other answer is a decline — an answer, not an error.
257
+ return { outcome: "navigate", action: "cancel" };
258
+ }
259
+ case "choice": {
260
+ const tokens = context.choiceTokens ?? [];
261
+ if (tokens.includes(lowered))
262
+ return { outcome: "accept", value: lowered };
263
+ // Exact full words map to their canonical letter, but only within
264
+ // their own question family: "no" at a p/f/n question must reprompt,
265
+ // never silently become "n". Never prefix-match either — "probably
266
+ // failed" or "not sure" reprompts, not guesses.
267
+ const alias = choiceWordAliases[lowered];
268
+ if (alias !== undefined && tokens.includes(alias)) {
269
+ const applies = lowered === "yes" || lowered === "no" ? tokens.includes("y") :
270
+ lowered === "passed" || lowered === "failed" ? tokens.includes("p") :
271
+ tokens.includes("h");
272
+ if (applies)
273
+ return { outcome: "accept", value: alias };
274
+ }
275
+ if (tokens.includes("p")) {
276
+ return reject("choice", "Answer p (passed), f (failed), or n (not run yet).");
277
+ }
278
+ if (tokens.includes("h")) {
279
+ return reject("choice", "Answer h (held), r (regressed), or m (cannot say).");
280
+ }
281
+ return reject("choice", `Answer one of: ${tokens.join(", ")}.`);
282
+ }
283
+ case "time": {
284
+ if (lowered === "now") {
285
+ return { outcome: "accept", value: new Date(context.nowMs ?? Date.now()).toISOString() };
286
+ }
287
+ if (!strictIsoPattern.test(answer)) {
288
+ return reject("time_invalid", "That is not a UTC ISO-8601 time. e.g. 2026-08-17T14:03:00Z — or type now if it just finished.");
289
+ }
290
+ const parsed = Date.parse(answer);
291
+ if (!Number.isFinite(parsed)) {
292
+ return reject("time_invalid", "That is not a UTC ISO-8601 time. e.g. 2026-08-17T14:03:00Z — or type now if it just finished.");
293
+ }
294
+ const approvedAt = context.approvedAtIso ? Date.parse(context.approvedAtIso) : undefined;
295
+ if (context.approvedAtIso !== undefined && !Number.isFinite(approvedAt ?? Number.NaN)) {
296
+ // Fail closed: an unreadable approval time must never silently
297
+ // disable the after-approval check.
298
+ return reject("time_invalid", "The approval record's own time is unreadable, so this time cannot be checked. Type cancel and rerun this command.");
299
+ }
300
+ if (approvedAt !== undefined && parsed <= approvedAt) {
301
+ return reject("time_before_approval", `That time is not after the approval at ${context.approvedAtIso}. A change cannot be applied before it was approved. Paste the time the agent reported.`);
302
+ }
303
+ if (parsed > (context.nowMs ?? Date.now()) + futureToleranceMs) {
304
+ return reject("time_future", "That time is in the future. Paste the actual reported time.");
305
+ }
306
+ return { outcome: "accept", value: new Date(parsed).toISOString() };
307
+ }
308
+ default:
309
+ break;
310
+ }
311
+ if (looksLikeShellCommand(answer)) {
312
+ const message = kind === "prose"
313
+ ? "That looks like a shell command, not an answer. Nothing runs here — describe it in words."
314
+ : "That looks like a shell command, not a name. Answer with the name in words.";
315
+ return reject("shell", message);
316
+ }
317
+ if (kind === "prose" && lowered === "keep" && (context.priorShellRejections ?? 0) >= 2) {
318
+ // After repeated identical shell rejections the user may type `keep` to
319
+ // record their exact text as words. The caller substitutes the last
320
+ // rejected line; this sentinel value never reaches storage.
321
+ return { outcome: "accept", value: "keep" };
322
+ }
323
+ if (looksLikePath(answer, kind)) {
324
+ return reject("path", "That looks like a file path. Describe it in words instead — the answer must read as a sentence, not a location.");
325
+ }
326
+ if (kind === "name" || kind === "team" || kind === "role" || kind === "optional") {
327
+ if (reservedVocabulary.has(lowered)) {
328
+ const message = kind === "role"
329
+ ? `"${answer}" is aibill's own vocabulary, not a role. Answer with your real job role, in words.${exampleSuffix}`
330
+ : `That is aibill vocabulary, not a name. Answer in your own words.${exampleSuffix}`;
331
+ return reject("reserved", message);
332
+ }
333
+ if (strictIsoPattern.test(answer)) {
334
+ return reject("timestamp_shaped", `That is a time, not a name.${exampleSuffix}`);
335
+ }
336
+ if (byteLength(answer) > 192) {
337
+ return reject("length", "That name is longer than aibill can store (192 bytes). Use a shorter form.");
338
+ }
339
+ return { outcome: "accept", value: answer.normalize("NFC") };
340
+ }
341
+ // prose
342
+ if (answer.length > 1000) {
343
+ return reject("length", "Keep it to one or two short sentences (under 1,000 characters).");
344
+ }
345
+ if (answer.split(/\s+/).filter(Boolean).length < 2 || isNonAnswer(lowered)) {
346
+ return reject("substance", `That is not something aibill can hold you to later. Write what should actually happen — or type back or cancel if you are not ready.${exampleSuffix}`);
347
+ }
348
+ // NFC like the name fields: the rollback sentence is later re-typed and
349
+ // compared by hash, so composition differences must not fail the match.
350
+ return { outcome: "accept", value: answer.normalize("NFC") };
351
+ }
352
+ //# sourceMappingURL=guidedAnswer.js.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./analyze.js";
2
+ export * from "./actionPlanner.js";
3
+ export * from "./actionVerification.js";
2
4
  export * from "./agentInventory.js";
3
5
  export * from "./agentEconomicsReceipt.js";
4
6
  export * from "./activitySnapshot.js";
@@ -12,16 +14,26 @@ export * from "./discovery.js";
12
14
  export * from "./glance.js";
13
15
  export * from "./toolInvocations.js";
14
16
  export * from "./insights.js";
15
- export { aggregateCalls, dedupeCumulativeSessionCalls, latestObservedWorkingDirectory, loadLocalAgentFinancialUsage, loadLocalAgentUsage, parseClaudeCodeTranscript, parseCodexRollout, sanitizeLocalActivityText } from "./localAgentLogs.js";
16
- export type { LocalAgentActivity, LocalAgentCall, LocalAgentFinancialLogOptions, LocalAgentLogDiagnostic, LocalAgentLogDiagnosticCode, LocalAgentLogOptions, LocalAgentLogResult, LocalAgentRateLimitSnapshot, LocalAgentRateLimitWindow, LocalAgentSourceScan, LocalAgentTurnUsage } from "./localAgentLogs.js";
17
+ export { aggregateCalls, codexHeaderProbesPerScan, dedupeCumulativeSessionCalls, defaultStreamedBytesPerRun, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, latestObservedWorkingDirectory, loadLocalAgentActionEvidence, loadLocalAgentFinancialUsage, loadLocalAgentUsage, localAgentQualitativeParserVersion, parseClaudeCodeTranscript, parseCodexRollout, SAFE_QUALITATIVE_SCAN_POLICY, sanitizeLocalActivityText } from "./localAgentLogs.js";
18
+ export type { LocalAgentActivity, LocalAgentCall, LocalAgentCompletionEvidence, LocalAgentFinancialLogOptions, LocalAgentOwnershipIndexAdapter, LocalAgentOwnershipRecord, LocalAgentQualitativeIndexAdapter, LocalAgentQualitativeIndexKey, LocalAgentQualitativeIndexValue, LocalAgentQualitativeScanPolicy, LocalAgentStreamCheckpointAdapter, LocalAgentStreamCheckpointRecord, LocalAgentLogDiagnostic, LocalAgentLogDiagnosticCode, LocalAgentLogOptions, LocalAgentLogResult, LocalAgentRateLimitSnapshot, LocalAgentRateLimitWindow, LocalAgentSourceScan, LocalAgentTokenComponentEvidence, LocalAgentTurnUsage } from "./localAgentLogs.js";
17
19
  export * from "./localAgentFormats/registry.js";
18
20
  export type * from "./localAgentFormats/types.js";
19
21
  export * from "./modelPricing.js";
20
22
  export * from "./planDetection.js";
21
23
  export * from "./planMath.js";
24
+ export * from "./projectEconomics.js";
25
+ export * from "./resultCard.js";
26
+ export * from "./projectEconomicsBuilder.js";
27
+ export * from "./qualitativeIndexCache.js";
28
+ export * from "./projectIndexStore.js";
29
+ export * from "./runtimeCommands.js";
30
+ export * from "./guidedAnswer.js";
31
+ export * from "./agentDraftToken.js";
32
+ export * from "./agentLoopContract.js";
22
33
  export * from "./sampleData.js";
23
34
  export * from "./scanGuard.js";
24
35
  export * from "./schema.js";
36
+ export * from "./sessionVitals.js";
25
37
  export * from "./sourceRegistry.js";
26
38
  export * from "./sourceStatus.js";
27
39
  export * from "./stateTrust.js";