/** * Discovery Engine * * The beating heart of `aegis init`. Just a conversation loop. * * When Aegis needs to think, the shield animation keeps the human * company — diamonds filling in, the .agentpolicy/ tree assembling. * When policy gets extracted, the same animations keep the human * company. The experience is: they talked to someone with real * presence, and then files appeared. */ import type { LLMProvider, Message } from "../llm/provider.js"; import type { ScanResult } from "./scanner.js"; import { type PostCompletionMode } from "./system-prompt.js"; import type { TerminalUI } from "../ui/terminal.js"; /** * Forensic record of how extraction failed, captured into the saved * session transcript so a subsequent debugging pass (Aegis on a * future return visit, or a human reading the audit trail) can see * the specific failure mode without re-running the failed call. * * Categories mirror the failure branches in extractPolicy(): * - "parse": JSON.parse threw a SyntaxError on the model's output. * - "transport": chatJSON threw a non-SyntaxError, non-MaxTokensError * (network, auth, rate limit, timeout, anything provider-side). * The model's output never reached the parser. * - "max_tokens": the provider stopped emitting tokens because the * max_tokens budget was exhausted before the JSON output completed. * Distinct from "parse" — the model didn't emit bad output, it ran * out of room. Captured separately so forensic passes can tell * "model truncated" from "model output malformed." * - "structural": the parsed object was missing one or more of the * required top-level keys (constitution, governance, roles, * ledger). * - "schema": the parsed object passed structural completeness but * failed ajv validation against one or more of the policy schemas. * * `detail` carries category-specific forensic information — the * parse error message, the missing-keys list, or the schema * validation summary. Bounded to a manageable size at population * time so the transcript record stays compact even when the model * emits a runaway error. */ export interface ExtractionFailure { category: "parse" | "transport" | "max_tokens" | "structural" | "schema"; detail: string; } export interface DiscoveryResult { /** The full conversation transcript */ transcript: Message[]; /** The compiled policy JSON, ready to write to disk — null if no changes needed */ policy: { constitution: Record; governance: Record; roles: Record>; ledger: Record; /** What the user should do next — build from scratch (multi/single agent) or govern existing codebase */ deployment_intent: "build_multi" | "build_single" | "govern"; /** Custom handoff prompt crafted by the extraction LLM from the full conversation context */ handoff_prompt: string; /** * Explicit list of role names the user asked to delete during a * return-visit conversation. Retires the prior delete-by- * omission semantics: the writer no longer infers deletion from * a role missing from `roles`. To delete a role, the extraction * LLM lists its name here. Roles that appear in neither `roles` * nor `deleted_role_names` are preserved on disk as a safety * measure (silent extraction drift can no longer cause data * loss). Optional and typically absent — only populated on * return visits where deletions were discussed. */ deleted_role_names?: string[]; } | null; /** * How the discovery conversation concluded. * - "completed": user affirmed changes, extraction ran successfully, policy is set. * - "no_changes": user explicitly confirmed nothing needs to change. * - "extraction_failed": user affirmed changes, but extraction returned null * after retries. The init command must NOT enter post-completion mode in * this state — doing so would tell the user "your policy was written" * when it wasn't. */ status: "completed" | "no_changes" | "extraction_failed"; /** * Populated only when status is "extraction_failed". Carries the * specific failure mode and detail that ended the session, so the * init command can preserve them in the saved transcript for * forensic review. */ extractionFailure?: ExtractionFailure; } export declare class DiscoveryEngine { private provider; private scan; private ui; private messages; private systemPrompt; /** * Engine-tracked consent for the one side effect Aegis can apply * automatically: appending the sensitive Aegis paths to .gitignore * during the policy-write phase. See gitignoreTopicOpen for the * substate machine that scopes consent/revoke markers to a * specific exchange rather than any matching user-turn signal. */ private gitignoreConsent; /** * True while an Aegis ask about the session-log privacy choice is * open and waiting for the human's answer. Set by the * [GITIGNORE_ASK] marker, cleared after a [GITIGNORE_CONSENT] or * [GITIGNORE_REVOKE] has been processed. Consent/revoke markers * are honored ONLY in this substate — that stops a drifted * [GITIGNORE_CONSENT] following an unrelated "yes" (about some * other question) from silently latching consent, and stops a * drifted [GITIGNORE_REVOKE] after an unrelated "don't do that" * from silently clearing it. To retract later in the session, * Aegis must re-emit [GITIGNORE_ASK] alongside the revoke. */ private gitignoreTopicOpen; constructor(provider: LLMProvider, scan: ScanResult, ui: TerminalUI); /** * Run the full discovery conversation. * Returns the compiled policy when complete, or null policy if no changes needed. */ run(): Promise; private switchModel; /** * Get a streamed response from Aegis. * * Thinking animation starts on a 2-second timer. If the first token * arrives before 2 seconds (the common case), no animation appears. * If it takes longer, the animation fills the pause naturally. * * The stream is buffered to intercept markers so they never appear * in the terminal. Three markers are recognized: [DISCOVERY_COMPLETE], * [NO_CHANGES], and [READ_FILE: ]. The first two are fixed * strings; the read marker is variable-length so the buffer holds * anything starting with '[' until the matching ']' arrives. * * If the response contains a [READ_FILE: …] marker, the engine reads * the requested file safely, appends a synthetic user message with * the contents, and recurses to let Aegis continue. Recursion is * bounded by MAX_READ_DEPTH. */ private getAegisResponse; /** * Fetch a file on Aegis's behalf during discovery. * * Returns a framed string the model can consume — either the file * contents wrapped in a system-note block, or an error note * explaining why the read was rejected. The string is injected as * a user message so the conversation continues coherently. * * Safety rules (mirrors scanner.ts): * - Reject paths containing ".." segments. * - Reject paths that resolve outside the project root. * - Reject paths matching SENSITIVE_FILE_PATTERNS. * - Delegate actual reading to readFileSafe (size caps, DOCX/PDF parsing). */ private readFileForAegis; /** * Send a message in post-completion mode and stream Aegis's response. * * Post-completion mode runs after extraction — the files are written, * the handoff has been shown, and the session stays open so the user * can ask follow-up questions. No markers, no extraction targets, no * policy edits: just continued conversation that gets captured in the * same transcript. */ continueConversation(userInput: string, mode?: PostCompletionMode): Promise; /** * Return a copy of the current message transcript. Used by the init * command to write the session transcript after the post-completion * loop ends, so the transcript captures the entire session. */ getTranscript(): Message[]; /** * True iff the human explicitly authorized Aegis to update .gitignore * during this session via an affirmed [GITIGNORE_CONSENT] marker. * The init command reads this to decide whether to append the * sanctioned Aegis paths to .gitignore after writePolicy. The * extraction output has no authority here — consent lives in the * engine's state, verified against the user's conversation turns. */ getGitignoreConsent(): boolean; /** * After discovery, compile the conversation into structured policy. * Thinking animation runs during extraction since this is always * a long operation — no 2-second threshold needed. * * On return visits, the existing policy contents are passed to the * extraction prompt as a baseline — the LLM applies the conversation's * changes on top of what already exists. * * Retries once on failure — large JSON outputs occasionally hit * token limits or produce syntax errors on the first attempt. */ private extractPolicy; /** * Format existing policy file contents as a baseline string * for the extraction prompt. Returns undefined when there is no * usable baseline — first-time init, or a return visit where the * on-disk state did not satisfy the full spec floor (constitution * + governance + ledger + at least one role) as parseable * non-empty JSON. Schema-drifted baselines are still returned: * extraction receives them together with migration findings so it * can fix only user-confirmed drift. */ private buildExistingPolicyBaseline; } export declare function handleDiscoverySlashCommand(input: string, ui: Pick, switchModel: () => Promise): Promise; /** * Detect whether a response ends in a question — used to guard against * the model emitting a completion marker in the same message where it's * still asking for the user's confirmation. * * Strips completion markers first (they can arrive after the question), * then checks the final 200 characters for a "?". */ export declare function containsTrailingQuestion(response: string): boolean; /** * Heuristic for "the user's most recent message was an unambiguous * affirmation." Used as a gate for completion markers — the engine * refuses to honor [DISCOVERY_COMPLETE] or [NO_CHANGES] unless the * user turn that preceded the marker reads as a clear go-ahead. * * Conservative by design. False negatives (treating a real * affirmation as non-affirmation) just mean the marker is dropped * and the conversation continues — Aegis re-asks, the user confirms * again, extraction proceeds. False positives (treating a * non-affirmation as affirmation) would let premature extraction * slip through, which is the whole thing we're guarding against. * * Rules: * - Empty or very long messages never count as simple affirmations. * - Any question, hedge, refusal, or new-instruction signal blocks * affirmation regardless of other content. * - The message must contain at least one recognized affirmative * token — "yes", "proceed", "looks good", "ship it", and similar. */ export declare function isSimpleAffirmative(userInput: string): boolean; /** * Last-resort completion salvage for model responses that clearly * acknowledge an affirmed wrap-up but omit the literal control marker. * * The checks are deliberately separate and ordered as hard gates: * a literal marker path owns marker-bearing responses, the user must * have affirmed, Aegis must not be asking a question, and the text * must contain completion-intent wording. Removing any one gate is a * review-visible behavior change, not a tweak to a combined regex. */ export declare function shouldSalvageDiscoveryComplete(userInput: string, response: string): boolean; /** * Gate for discovery completion. Short standalone confirmations use * isSimpleAffirmative; longer messages are accepted only when they * end with an explicit final go-ahead and do not carry late-change * signals, so "yes, but change X. Draft it." stays blocked while * "those two clarifications are correct. Draft it." can close. */ export declare function isDiscoveryCompletionAffirmation(userInput: string): boolean; export declare function normalizeExtractedPolicyShapes(policy: Partial>): void; //# sourceMappingURL=engine.d.ts.map