@yagni-app/code 0.2.1 → 0.3.0

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 (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
3
+ *
4
+ * Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
5
+ * I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
6
+ * the advisor uses) on the efficient tier with read-only tools and a risk
7
+ * policy persona. The child returns a JSON verdict; the gate acts on it.
8
+ *
9
+ * Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
10
+ * and permission.ts (decideGate pure, registerPermissionGate I/O), for the
11
+ * same reason: the rules are what need exhaustive tests, and they must not
12
+ * require a child process to exercise.
13
+ *
14
+ * Trigger: the exec policy classifies a bash command as "prompt" (not clearly
15
+ * safe, not clearly forbidden). The Guardian reviews it instead of interrupting
16
+ * the user. On allow, the command runs. On ask (YAG-510), the user arbitrates:
17
+ * the gate shows the Guardian's question-rationale and the user approves or
18
+ * declines. On deny, the agent sees the rationale and is told to find a safer
19
+ * alternative or ask the user. On timeout/error, auto mode falls back to an
20
+ * ask when a UI exists, else fails closed; review mode falls back to the
21
+ * ordinary user confirm.
22
+ *
23
+ * Circuit breaker: 3 consecutive denials within one user prompt → escalation
24
+ * (ask the user once) or, headless, interruption. Denial streaks reset on
25
+ * `before_agent_start`, which fires once per USER PROMPT (not per LLM turn).
26
+ * An `ask` outcome leaves the streak untouched — neither a denial nor an
27
+ * exoneration — so an ask-preferring model cannot disarm the breaker by
28
+ * alternating deny/ask.
29
+ */
30
+ import { runStage as defaultRunStage } from "./pipeline/runner.js";
31
+ export const DEFAULT_GUARDIAN_LIMITS = {
32
+ maxReviews: 30,
33
+ maxConsecutiveDenials: 3,
34
+ timeoutMs: 15_000,
35
+ };
36
+ /**
37
+ * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
38
+ * overrides the session review cap; anything non-numeric or < 1 falls back to
39
+ * the default (a bad value must never zero out the cap and lock the session).
40
+ */
41
+ export function resolveGuardianLimits(env = process.env) {
42
+ const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
43
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
44
+ const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
45
+ return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
46
+ }
47
+ /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
48
+ export const GUARDIAN_MODEL_TIER = "efficient";
49
+ /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
50
+ export const GUARDIAN_TOOLS = ["read"];
51
+ export function makeGuardianState() {
52
+ const state = { reviews: 0, consecutiveDenials: 0 };
53
+ return {
54
+ read: () => ({ ...state }),
55
+ recordReview(outcome) {
56
+ state.reviews += 1;
57
+ if (outcome === "deny") {
58
+ state.consecutiveDenials += 1;
59
+ }
60
+ else if (outcome === "allow") {
61
+ state.consecutiveDenials = 0;
62
+ }
63
+ // "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
64
+ // an exoneration. If it reset the streak, deny/ask/deny/ask would never
65
+ // trip the breaker (round-2 review blocker).
66
+ return { ...state };
67
+ },
68
+ resetTurn() {
69
+ state.consecutiveDenials = 0;
70
+ },
71
+ };
72
+ }
73
+ export function checkCircuitBreaker(state, limits) {
74
+ if (state.consecutiveDenials >= limits.maxConsecutiveDenials) {
75
+ return {
76
+ tripped: true,
77
+ reason: `Guardian denied ${state.consecutiveDenials} consecutive actions this turn. Pausing for human review.`,
78
+ };
79
+ }
80
+ return { tripped: false };
81
+ }
82
+ // --- Verdict parsing (fail closed on malformed) ---
83
+ export function parseVerdict(raw) {
84
+ try {
85
+ // Efficient-tier models may wrap JSON in markdown fences despite
86
+ // instructions to output raw JSON. Extract the first {...} block.
87
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
88
+ const jsonStr = jsonMatch ? jsonMatch[0] : raw;
89
+ const parsed = JSON.parse(jsonStr);
90
+ const outcome = parsed?.outcome;
91
+ if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
92
+ return null;
93
+ const riskLevel = parsed.riskLevel;
94
+ const validLevels = ["low", "medium", "high", "critical"];
95
+ return {
96
+ outcome,
97
+ riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
98
+ ? riskLevel
99
+ : "medium",
100
+ rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
101
+ ? parsed.rationale.trim()
102
+ : "No rationale provided.",
103
+ };
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ // --- /cost subtotal ---
110
+ export function formatGuardianSubtotal(state, limits) {
111
+ if (state.reviews === 0)
112
+ return "";
113
+ const plural = state.reviews === 1 ? "review" : "reviews";
114
+ return `Guardian: ${state.reviews} ${plural}.`;
115
+ }
116
+ /**
117
+ * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
118
+ * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
119
+ * agent name selects the guardian persona from PERSONA_BODIES.
120
+ */
121
+ export function guardianStage(modelTier = GUARDIAN_MODEL_TIER) {
122
+ return {
123
+ id: "plan",
124
+ agent: "guardian",
125
+ model: modelTier,
126
+ tools: GUARDIAN_TOOLS,
127
+ taskTemplate: "{ticket}",
128
+ };
129
+ }
130
+ /**
131
+ * Run a Guardian consult: spawn a locked-down child pi with the risk policy
132
+ * persona and the command as the task. Parse the JSON verdict from the output.
133
+ * Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
134
+ */
135
+ export async function reviewCommand(command, deps) {
136
+ const runStage = deps.runStage ?? defaultRunStage;
137
+ const stage = guardianStage(deps.modelTier);
138
+ // Build the abort signal with the Guardian timeout (injectable — the gate
139
+ // threads guardianLimits.timeoutMs through).
140
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_GUARDIAN_LIMITS.timeoutMs;
141
+ const controller = new AbortController();
142
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
143
+ timer.unref?.();
144
+ // Chain with the caller's signal if present.
145
+ if (deps.signal) {
146
+ if (deps.signal.aborted)
147
+ controller.abort();
148
+ else
149
+ deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
150
+ }
151
+ try {
152
+ const justificationLine = deps.execJustification
153
+ ? `\n\nWhy this command was routed to you (static policy): ${deps.execJustification}`
154
+ : "";
155
+ const result = await runStage(stage, { ticket: `Review this shell command for safety. Output only the JSON verdict.${justificationLine}\n\nCommand:\n${command}` }, {
156
+ cwd: deps.cwd,
157
+ signal: controller.signal,
158
+ callerLabel: "guardian",
159
+ });
160
+ const cost = result.usage?.cost ?? 0;
161
+ const output = result.finalOutput.trim();
162
+ // An aborted child often RESOLVES with empty/partial output instead of
163
+ // throwing (observed live: ESC → 433ms "empty"). Any result produced
164
+ // while the caller's signal is aborted is an abort, not an outage — the
165
+ // gate must not warn or fall back to an ask on it.
166
+ if (deps.signal?.aborted) {
167
+ return { verdict: null, error: "aborted", cost };
168
+ }
169
+ if (!output) {
170
+ return { verdict: null, error: "empty", cost };
171
+ }
172
+ const verdict = parseVerdict(output);
173
+ if (!verdict) {
174
+ return { verdict: null, error: "malformed", cost };
175
+ }
176
+ return { verdict, cost };
177
+ }
178
+ catch (err) {
179
+ // Distinguish the caller aborting (user hit ESC — must NOT be treated as
180
+ // an outage, or the fallback ask would pop a dialog on an aborted turn),
181
+ // our own timeout, and real network/process errors.
182
+ if (deps.signal?.aborted) {
183
+ return { verdict: null, error: "aborted", cost: 0 };
184
+ }
185
+ if (controller.signal.aborted) {
186
+ return { verdict: null, error: "timeout", cost: 0 };
187
+ }
188
+ return { verdict: null, error: "network", cost: 0 };
189
+ }
190
+ finally {
191
+ clearTimeout(timer);
192
+ }
193
+ }
194
+ /**
195
+ * Create a sanitized diagnostic event. Never includes the raw command text
196
+ * (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
197
+ */
198
+ export function buildDiagnosticEvent(outcome, opts) {
199
+ const ev = {
200
+ event: "guardian_review",
201
+ outcome,
202
+ ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
203
+ ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
204
+ };
205
+ if (opts.debug) {
206
+ if (opts.rationale)
207
+ ev.rationale = opts.rationale;
208
+ if (opts.commandHash)
209
+ ev.commandHash = opts.commandHash;
210
+ }
211
+ return ev;
212
+ }
213
+ //# sourceMappingURL=guardian.js.map
@@ -4,7 +4,7 @@ import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
5
5
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
6
  import { type TokenProvider } from "./tokenProvider.js";
7
- import { type ContextBrief, type ModelEntry } from "./config.js";
7
+ import { type CatalogResult, type ContextBrief } from "./config.js";
8
8
  /**
9
9
  * YAGNI Code extension entry point.
10
10
  *
@@ -31,7 +31,7 @@ export interface RegisterYagniDeps {
31
31
  baseUrl: string;
32
32
  getToken: () => string | undefined;
33
33
  fetchImpl?: typeof fetch;
34
- }) => Promise<ModelEntry[]>;
34
+ }) => Promise<CatalogResult>;
35
35
  fetchContextBrief?: (opts: {
36
36
  baseUrl: string;
37
37
  getToken: () => string | undefined;
@@ -63,6 +63,12 @@ export interface RegisterYagniDeps {
63
63
  tokenProvider?: TokenProvider;
64
64
  /** The spool flush (R4 write half), injectable so tests never touch disk. */
65
65
  flushSpool?: (opts: SpoolClientOpts) => Promise<FlushOutcome>;
66
+ /**
67
+ * Non-fatal auth-event reporter (YAG-500 Fix E). Defaults to
68
+ * `makeCrashReporter` gated on `!evalMode`; inject a spy in tests to assert
69
+ * the report is fired with `context: "auth-failure"` and the refresh outcome.
70
+ */
71
+ authReporter?: (error: unknown, context?: string) => Promise<void>;
66
72
  env?: NodeJS.ProcessEnv;
67
73
  }
68
74
  /**
@@ -97,8 +103,11 @@ export declare function parseSpendResponse(data: unknown): SpendResponse | null;
97
103
  export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
98
104
  export default function (pi: ExtensionAPI): Promise<void>;
99
105
  export { makeAskYagniTool } from "./askYagniTool.js";
106
+ export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
100
107
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
101
108
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
109
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
110
+ export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
102
111
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
103
112
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
104
113
  export type { MakeReviewToolOptions } from "./reviewTool.js";
@@ -115,7 +124,7 @@ export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutco
115
124
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
116
125
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
117
126
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
118
- export type { FetchCatalogOptions, ModelEntry } from "./config.js";
127
+ export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
119
128
  export { buildYagniProvider } from "./provider.js";
120
129
  export { registerGoCommand } from "./pipeline/goCommand.js";
121
130
  export type { RegisterGoDeps } from "./pipeline/goCommand.js";
@@ -134,7 +143,9 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
134
143
  export type { SubagentDef, SubagentSource } from "./subagents.js";
135
144
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
136
145
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
137
- export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
146
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
147
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
148
+ export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./execPolicy.js";
138
149
  export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission.js";
139
150
  export { makeBlessStore, blessPath } from "./bless.js";
140
151
  export type { BlessStore, BlessRule } from "./bless.js";