@yagni-app/code 0.3.2 → 0.3.3

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 (58) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/extension/footer.d.ts +1 -1
  3. package/dist/extension/hooks.d.ts +111 -0
  4. package/dist/extension/hooks.js +666 -0
  5. package/dist/extension/index.d.ts +13 -6
  6. package/dist/extension/index.js +57 -7
  7. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  8. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  9. package/dist/extension/permission/dbReadPolicy.js +227 -0
  10. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +41 -13
  11. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +9 -2
  12. package/dist/extension/{permission.js → permission/gate.js} +103 -4
  13. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  14. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  15. package/dist/extension/permission/index.d.ts +14 -0
  16. package/dist/extension/permission/index.js +14 -0
  17. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  18. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  19. package/dist/extension/pipeline/activityFeed.js +19 -5
  20. package/dist/extension/pipeline/checker.d.ts +99 -0
  21. package/dist/extension/pipeline/checker.js +238 -0
  22. package/dist/extension/pipeline/fanout.d.ts +116 -0
  23. package/dist/extension/pipeline/fanout.js +248 -0
  24. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  25. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  26. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  27. package/dist/extension/pipeline/goCommand.js +38 -1
  28. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  29. package/dist/extension/pipeline/headlessGo.js +333 -0
  30. package/dist/extension/pipeline/invocation.d.ts +7 -1
  31. package/dist/extension/pipeline/invocation.js +7 -1
  32. package/dist/extension/pipeline/mission.d.ts +55 -0
  33. package/dist/extension/pipeline/mission.js +70 -0
  34. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  35. package/dist/extension/pipeline/orchestrator.js +450 -9
  36. package/dist/extension/pipeline/personas.d.ts +16 -1
  37. package/dist/extension/pipeline/personas.js +117 -6
  38. package/dist/extension/pipeline/runSession.d.ts +45 -1
  39. package/dist/extension/pipeline/runState.d.ts +57 -12
  40. package/dist/extension/pipeline/runState.js +60 -18
  41. package/dist/extension/pipeline/runner.js +10 -1
  42. package/dist/extension/pipeline/stages.d.ts +84 -7
  43. package/dist/extension/pipeline/stages.js +166 -0
  44. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  45. package/dist/extension/pipeline/tierCap.js +57 -0
  46. package/dist/extension/pipeline/types.d.ts +130 -1
  47. package/dist/extension/pipeline/types.js +17 -0
  48. package/dist/extension/pipeline/verify.d.ts +86 -3
  49. package/dist/extension/pipeline/verify.js +175 -6
  50. package/dist/extension/turnLog.d.ts +38 -0
  51. package/dist/extension/turnLog.js +93 -0
  52. package/dist/goHeadless.d.ts +75 -0
  53. package/dist/goHeadless.js +132 -0
  54. package/dist/paths.d.ts +9 -0
  55. package/dist/paths.js +12 -0
  56. package/package.json +2 -2
  57. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  58. /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
@@ -121,8 +121,8 @@ export { makeAskYagniTool } from "./askYagniTool.js";
121
121
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
122
122
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
123
123
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
124
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
125
- export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
124
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
125
+ export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
126
126
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
127
127
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
128
128
  export type { MakeReviewToolOptions } from "./reviewTool.js";
@@ -138,6 +138,8 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
138
138
  export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
139
139
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
140
140
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
141
+ export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
142
+ export type { HookEntry, HookGroup, HooksConfig, HookEventName, HookRunner, PreToolUseHookResult, PermissionRequestHookResult, RegisterHooksDeps, } from "./hooks.js";
141
143
  export { createUltraHolder, registerUltraCommand } from "./ultra.js";
142
144
  export type { UltraHolder } from "./ultra.js";
143
145
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
@@ -147,6 +149,11 @@ export { registerGoCommand } from "./pipeline/goCommand.js";
147
149
  export type { RegisterGoDeps } from "./pipeline/goCommand.js";
148
150
  export { runPipeline } from "./pipeline/orchestrator.js";
149
151
  export type { RunPipelineDeps } from "./pipeline/orchestrator.js";
152
+ export { HEADLESS_GO_EXIT, HEADLESS_GO_USAGE, parseHeadlessGoArgs, runHeadlessGo, validateHeadlessGoArgs, } from "./pipeline/headlessGo.js";
153
+ export type { HeadlessGoArgs, HeadlessGoDeps, HeadlessGoOutcome, HeadlessGoResult, HeadlessStageRow, } from "./pipeline/headlessGo.js";
154
+ export { isMissionMode, missionSeed, missionSkippedStages, normalizeMission } from "./pipeline/mission.js";
155
+ export type { MissionInputs } from "./pipeline/mission.js";
156
+ export { clampTier, parseTierCap, resolveTierCap, TIER_CAP_ENV } from "./pipeline/tierCap.js";
150
157
  export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
151
158
  export type { ResilienceAttemptRecord, ResilienceSeams, RunStageFn } from "./pipeline/resilience.js";
152
159
  export { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
@@ -160,10 +167,10 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
160
167
  export type { SubagentDef, SubagentSource } from "./subagents.js";
161
168
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
162
169
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
163
- export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
164
- export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
165
- export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./execPolicy.js";
166
- export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission.js";
170
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
171
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
172
+ export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./permission/execPolicy.js";
173
+ export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission/gate.js";
167
174
  export { makeBlessStore, blessPath } from "./bless.js";
168
175
  export type { BlessStore, BlessRule } from "./bless.js";
169
176
  export { bankDecision, fetchDecisions, supersedeDecision, parseDecideArgs, formatDecisionsList, shortId, registerDecisionCommands, DEFAULT_LIST_LIMIT, } from "./decisions.js";
@@ -3,9 +3,9 @@ import { appendFileSync, mkdirSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
- import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./approvedPrefixes.js";
6
+ import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
7
7
  import { redactCommand } from "./redact.js";
8
- import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
8
+ import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./permission/guardian.js";
9
9
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
10
10
  import { makeAskYagniTool } from "./askYagniTool.js";
11
11
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
@@ -20,6 +20,7 @@ import { registerCostCommand } from "./costHud.js";
20
20
  import { isDebug } from "./diagnostics.js";
21
21
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
22
22
  import { codeStateHome } from "./stateHome.js";
23
+ import { logTurnLifecycle } from "./turnLog.js";
23
24
  import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
24
25
  import { RerouteNotifier } from "./rerouteNotice.js";
25
26
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
@@ -27,7 +28,8 @@ import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone }
27
28
  import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
28
29
  import { registerGoCommand } from "./pipeline/goCommand.js";
29
30
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
30
- import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
31
+ import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
32
+ import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
31
33
  import { registerSubagents } from "./subagents.js";
32
34
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
33
35
  import { registerTodos } from "./todos.js";
@@ -139,7 +141,12 @@ export async function registerYagni(pi, deps = {}) {
139
141
  // `yagni` provider, so /model and Ctrl+P show a single entry. Child
140
142
  // processes (/go, subagents, advisor) fetch their own catalog and register
141
143
  // their own provider, so they are unaffected by this filter.
142
- const catalog = fullCatalog.filter((m) => m.id === "advanced");
144
+ //
145
+ // Eval mode is exempt: a headless harness (scoping sessions, code evals)
146
+ // names its tier explicitly — the backend's scoping sessions run `--model
147
+ // standard` — and there is no /model picker to keep tidy. Filtering there
148
+ // turns a valid tier request into "no models match".
149
+ const catalog = evalMode ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
143
150
  // YAG-471: the driver's own completions carry attribution headers read from
144
151
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
145
152
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -206,6 +213,10 @@ export async function registerYagni(pi, deps = {}) {
206
213
  // The grounded multi-agent pipeline entry point: /go <ticket> runs
207
214
  // map → plan → implement → review → fix, each child grounded by inheritance.
208
215
  registerGoCommand(pi, {
216
+ // /ultra is one dial for the whole session: the same holder the subagent
217
+ // tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
218
+ // the fan and its fix turns. Read per run, so a toggle lands on the next /go.
219
+ isUltra: () => ultraHolder.get(),
209
220
  // Task 8: /go's end-of-run summary prefers the server-priced per-stage
210
221
  // breakdown for the ONE run that just finished (`?runId=`, aliasing the
211
222
  // spend endpoint's `sessionId` param — see the backend route), over
@@ -280,6 +291,12 @@ export async function registerYagni(pi, deps = {}) {
280
291
  // rejected as a same-session self-authorization path, PR #1698).
281
292
  const sessionGrants = evalMode ? [] : loadGrants();
282
293
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
294
+ // YAG-506: load user-configurable lifecycle hooks config and create the
295
+ // hook runner for the permission gate. Skipped in eval mode.
296
+ const hooksConfig = evalMode ? {} : loadHooksConfig();
297
+ const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
298
+ if (!evalMode)
299
+ registerHooks(pi, { config: hooksConfig });
283
300
  registerPermissionGate(pi, {
284
301
  modeHolder,
285
302
  guardianState,
@@ -362,6 +379,7 @@ export async function registerYagni(pi, deps = {}) {
362
379
  onBlessRemember: decisionCapture
363
380
  ? (ctx, info) => decisionCapture.captureFromBless(ctx, info)
364
381
  : undefined,
382
+ ...(hookRunner ? { hookRunner } : {}),
365
383
  });
366
384
  // P2/YAG-383: /cost prefers the server-authoritative session spend (covers
367
385
  // subagents and advisor consults directly, since they bill under this same
@@ -564,6 +582,28 @@ export async function registerYagni(pi, deps = {}) {
564
582
  rulesSection,
565
583
  }),
566
584
  });
585
+ // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
586
+ // silent-freeze tell (the turn that left the UI stuck on "Working…"), even
587
+ // if the process is later killed. `turn_end` records the final stopReason so
588
+ // an `error` / `aborted` end is visible at a glance without reading the raw
589
+ // session .jsonl. Fail-soft; nothing here may break a turn.
590
+ const turnStartMs = new Map();
591
+ const sessionIdForLog = () => deps.env?.YAGNI_SESSION_ID;
592
+ pi.on("turn_start", (event) => {
593
+ turnStartMs.set(event.turnIndex, Date.now());
594
+ logTurnLifecycle({ kind: "turn_start", sessionId: sessionIdForLog(), turnIndex: event.turnIndex });
595
+ });
596
+ pi.on("turn_end", (event) => {
597
+ const startedAt = turnStartMs.get(event.turnIndex);
598
+ turnStartMs.delete(event.turnIndex);
599
+ logTurnLifecycle({
600
+ kind: "turn_end",
601
+ sessionId: sessionIdForLog(),
602
+ turnIndex: event.turnIndex,
603
+ stopReason: event.message?.stopReason,
604
+ elapsedMs: startedAt !== undefined ? Date.now() - startedAt : undefined,
605
+ });
606
+ });
567
607
  // Two finalized-message guards share this handler (their conditions are
568
608
  // mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
569
609
  // stop/length ones without an errorMessage).
@@ -847,7 +887,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
847
887
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
848
888
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
849
889
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
850
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
890
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
851
891
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
852
892
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
853
893
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -859,12 +899,22 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
859
899
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
860
900
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
861
901
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
902
+ // YAG-506: user-configurable lifecycle hooks.
903
+ export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
862
904
  // Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
863
905
  export { createUltraHolder, registerUltraCommand } from "./ultra.js";
864
906
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
865
907
  export { buildYagniProvider } from "./provider.js";
866
908
  export { registerGoCommand } from "./pipeline/goCommand.js";
867
909
  export { runPipeline } from "./pipeline/orchestrator.js";
910
+ // The headless front door (`yagni go --headless`): the same pipeline, driven by
911
+ // a script or the mission sandbox instead of a session.
912
+ export { HEADLESS_GO_EXIT, HEADLESS_GO_USAGE, parseHeadlessGoArgs, runHeadlessGo, validateHeadlessGoArgs, } from "./pipeline/headlessGo.js";
913
+ // Mission mode: the pure rules for entering the pipeline on an already-approved
914
+ // plan (map/plan skipped, memo as the stand-in repo brief, FINISH not ours).
915
+ export { isMissionMode, missionSeed, missionSkippedStages, normalizeMission } from "./pipeline/mission.js";
916
+ // The eval/smoke tier ceiling (YAGNI_GO_TIER_CAP), clamped centrally in runStage.
917
+ export { clampTier, parseTierCap, resolveTierCap, TIER_CAP_ENV } from "./pipeline/tierCap.js";
868
918
  // R1: the in-loop resilience HOF a dev can compose with (or replace at) the
869
919
  // `runStage` seam, plus its policy type and the default policy.
870
920
  export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
@@ -880,8 +930,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
880
930
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
881
931
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
882
932
  // the session bless-with-remember capture hook.
883
- export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
884
- export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
933
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
934
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
885
935
  // W4 judgment loop: the session bless store (tool + path-prefix, session-only).
886
936
  export { makeBlessStore, blessPath } from "./bless.js";
887
937
  // W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
29
29
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  import { classifyCommand, shellParse, tokenize } from "./execPolicy.js";
32
- import { codeStateHome } from "./stateHome.js";
32
+ import { codeStateHome } from "../stateHome.js";
33
33
  // --- Derivation ---
34
34
  /** Tools whose second token is a subcommand worth capturing in a prefix. */
35
35
  const MULTI_SUBCOMMAND_TOOLS = new Set([
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Database read auto-allow policy (YAG-561).
3
+ *
4
+ * Auto-allows *provably read-only* inline SQL for a small set of database
5
+ * clients, so routine SELECTs never reach the Guardian. Anything we cannot
6
+ * prove read-only is left ENTIRELY to the Guardian — this module has no
7
+ * "deny" outcome (see the invariant below).
8
+ *
9
+ * The hard, easy part is extracting the SQL safely WITHOUT regex over the raw
10
+ * command. We reuse execPolicy's shellParse — the same quote-aware tokenizer
11
+ * that powers the whole classifier — so `-c "SELECT 1; SELECT 2"` arrives as
12
+ * one clean, dequoted token and a `;` OUTSIDE quotes still reads as a real
13
+ * command separator. Extraction failures (unknown flag, unknown short-cluster,
14
+ * `-f file`, stdin `<`) simply left as `readonly: false`.
15
+ *
16
+ * ── The single invariant ────────────────────────────────────────────────
17
+ * classifySql returns "read" ONLY when the SQL is provably read-only. A
18
+ * false "read" auto-runs a write across every workspace's DB, which is
19
+ * unacceptable; a false "unknown" costs one Guardian consult, which is free.
20
+ * We therefore over-reject: a write marker, a mutating function name, or a
21
+ * `\` meta-command ANYWHERE in the SQL downgrades the whole thing to
22
+ * "unknown". No attempt is made to distinguish "called" from "mentioned in
23
+ * a string literal" — erring toward prompt is always the safe direction.
24
+ *
25
+ * NOTE: a non-"read" result is NOT "forbidden". An UPDATE/INSERT/DELETE is
26
+ * sent to the Guardian where a human/LLM judges intent. This module never
27
+ * flat-denies a DB write.
28
+ *
29
+ * ── How to extend ────────────────────────────────────────────────────────
30
+ * Add a new client: push a DbClientSpec to DB_CLIENTS.
31
+ * Add a new mutating function: append to the relevant client's
32
+ * mutatingFunctions list (schema-prefix-agnostic word-boundary match).
33
+ * Add a new way to pass SQL: add a SqlArgExtractor to the client.
34
+ * Every change needs a paired allow + regression test in dbReadPolicy.test.ts.
35
+ * See AGENTS.md alongside this directory for the full recipe.
36
+ */
37
+ /** A single argv shape that carries inline SQL (e.g. psql `-c "…"`). */
38
+ export interface SqlArgExtractor {
39
+ /**
40
+ * Token forms introducing SQL: `-c`, `--command`, `-e`, `--execute`, … The
41
+ * extractor accepts these in three forms: glued long (`--command=…`), glued
42
+ * short (`-c…` for the 2-char flags only), and bare (`-c` → SQL is the next
43
+ * token). A bare single-dash flag may also match a short cluster
44
+ * (`-Atc`) when its terminal letter is `shortCluster.terminal` and every
45
+ * preceding letter is in `shortCluster.allowedPrefixes`.
46
+ */
47
+ flags: readonly string[];
48
+ shortCluster?: {
49
+ terminal: string;
50
+ allowedPrefixes: readonly string[];
51
+ };
52
+ }
53
+ /** Everything we know about one database client. */
54
+ export interface DbClientSpec {
55
+ binaries: readonly string[];
56
+ sqlArgExtractors: readonly SqlArgExtractor[];
57
+ /** Is this (dequoted, full) SQL provably read-only? */
58
+ classifySql: (sql: string) => boolean;
59
+ /** Function names that make a bare SELECT mutate/abuse — reject as unknown. */
60
+ mutatingFunctions: readonly string[];
61
+ }
62
+ /**
63
+ * The shared "provably read-only" gate. Returns true only when every
64
+ * statement in `sql` (after comment-stripping) begins with SELECT/WITH and
65
+ * contains no write verb, write clause, mutating function, or backslash
66
+ * meta-command.
67
+ */
68
+ export declare function isSqlReadOnly(sql: string, mutatingFunctions: readonly string[]): boolean;
69
+ /** The supported database clients. Add a new one here to expand coverage. */
70
+ export declare const DB_CLIENTS: DbClientSpec[];
71
+ /**
72
+ * Resolve the SQL string for a known client binary from its tokenized argv.
73
+ * Returns `{ sql }` on success (the dequoted SQL as one string) or null when
74
+ * the argv shape can't be proven to carry inline SQL. Null → left to Guardian.
75
+ *
76
+ * Supported token shapes, in the order tried:
77
+ * 1. glued long flag `--command=SELECT …` / `--execute=SELECT …`
78
+ * 2. glued short flag `-cSELECT …` / `-eSELECT …` (2-char flag + payload)
79
+ * 3. bare flag `-c` / `--command` / `-Atc` → SQL is the NEXT token
80
+ */
81
+ export declare function extractSqlArg(tokens: readonly string[], spec: DbClientSpec): {
82
+ sql: string;
83
+ } | null;
84
+ /**
85
+ * Top-level: should this database command be auto-allowed? Consumes the raw
86
+ * tokenized argv (tokens[0] is the binary) and returns true ONLY when the
87
+ * inline SQL was extracted AND classified read-only.
88
+ */
89
+ export declare function classifyDbRead(tokens: readonly string[]): boolean;
90
+ //# sourceMappingURL=dbReadPolicy.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Database read auto-allow policy (YAG-561).
3
+ *
4
+ * Auto-allows *provably read-only* inline SQL for a small set of database
5
+ * clients, so routine SELECTs never reach the Guardian. Anything we cannot
6
+ * prove read-only is left ENTIRELY to the Guardian — this module has no
7
+ * "deny" outcome (see the invariant below).
8
+ *
9
+ * The hard, easy part is extracting the SQL safely WITHOUT regex over the raw
10
+ * command. We reuse execPolicy's shellParse — the same quote-aware tokenizer
11
+ * that powers the whole classifier — so `-c "SELECT 1; SELECT 2"` arrives as
12
+ * one clean, dequoted token and a `;` OUTSIDE quotes still reads as a real
13
+ * command separator. Extraction failures (unknown flag, unknown short-cluster,
14
+ * `-f file`, stdin `<`) simply left as `readonly: false`.
15
+ *
16
+ * ── The single invariant ────────────────────────────────────────────────
17
+ * classifySql returns "read" ONLY when the SQL is provably read-only. A
18
+ * false "read" auto-runs a write across every workspace's DB, which is
19
+ * unacceptable; a false "unknown" costs one Guardian consult, which is free.
20
+ * We therefore over-reject: a write marker, a mutating function name, or a
21
+ * `\` meta-command ANYWHERE in the SQL downgrades the whole thing to
22
+ * "unknown". No attempt is made to distinguish "called" from "mentioned in
23
+ * a string literal" — erring toward prompt is always the safe direction.
24
+ *
25
+ * NOTE: a non-"read" result is NOT "forbidden". An UPDATE/INSERT/DELETE is
26
+ * sent to the Guardian where a human/LLM judges intent. This module never
27
+ * flat-denies a DB write.
28
+ *
29
+ * ── How to extend ────────────────────────────────────────────────────────
30
+ * Add a new client: push a DbClientSpec to DB_CLIENTS.
31
+ * Add a new mutating function: append to the relevant client's
32
+ * mutatingFunctions list (schema-prefix-agnostic word-boundary match).
33
+ * Add a new way to pass SQL: add a SqlArgExtractor to the client.
34
+ * Every change needs a paired allow + regression test in dbReadPolicy.test.ts.
35
+ * See AGENTS.md alongside this directory for the full recipe.
36
+ */
37
+ // --- Read-only classifier helpers ---
38
+ /** Strip leading/leading whitespace and `/* … *​/` block + `-- …` line comments. */
39
+ function stripSqlComments(sql) {
40
+ let out = sql;
41
+ // Block comments (non-greedy, across newlines).
42
+ out = out.replace(/\/\*[\s\S]*?\*\//g, " ");
43
+ // Line comments to end-of-line.
44
+ out = out.replace(/--[^\n]*/g, " ");
45
+ return out;
46
+ }
47
+ /** Write verbs / statements that are never read-only. */
48
+ const WRITE_VERBS = /\b(insert|update|delete|truncate|drop|create|alter|grant|revoke|copy|vacuum|merge|call|set|replace|load)\b/i;
49
+ /** Statements that use SELECT but still write (INTO, FOR UPDATE, …). */
50
+ const WRITE_CLAUSES = /\b(into\s+(outfile|dumpfile|new\s+table|table)|for\s+(update|share)|returning)\b/i;
51
+ /** Match a function name with a word boundary (catches `schema.fn` and `fn(`). */
52
+ function mentionsFunction(sql, fn) {
53
+ const re = new RegExp(`(^|[^\\w])${fn}\\s*\\(`, "i");
54
+ return re.test(sql);
55
+ }
56
+ /**
57
+ * The shared "provably read-only" gate. Returns true only when every
58
+ * statement in `sql` (after comment-stripping) begins with SELECT/WITH and
59
+ * contains no write verb, write clause, mutating function, or backslash
60
+ * meta-command.
61
+ */
62
+ export function isSqlReadOnly(sql, mutatingFunctions) {
63
+ const stripped = stripSqlComments(sql).trim();
64
+ // Any backslash is a psql/mysql meta-command (`\copy`, `\!`, `\i`) — not a
65
+ // plain SQL statement. Never auto-allow.
66
+ if (stripped.includes("\\"))
67
+ return false;
68
+ // A mutating function ANYWHERE (schema-prefixed, string-literal mention, or
69
+ // genuine call) downgrades the whole thing. Over-reject on purpose.
70
+ if (mutatingFunctions.some((fn) => mentionsFunction(stripped, fn)))
71
+ return false;
72
+ // Write verbs / clauses anywhere → not read-only. (Single pass over the whole
73
+ // multi-statement string means any `;`-joined write poisons the read.)
74
+ if (WRITE_VERBS.test(stripped))
75
+ return false;
76
+ if (WRITE_CLAUSES.test(stripped))
77
+ return false;
78
+ // The first statement must open with SELECT or WITH. (A leading `WITH`
79
+ // CTE is safe, but `WITH` can also be INSERT … WITH — the WRITE_VERBS
80
+ // check already caught any write verb, so a remaining `WITH` lead is a CTE.)
81
+ if (!/^(select|with)\b/i.test(stripped))
82
+ return false;
83
+ return true;
84
+ }
85
+ // --- Client registry ---
86
+ /**
87
+ * PostgreSQL. Inline SQL via `-c` / `--command` (glued or `=`) and the
88
+ * `-Atc`-style short cluster. Mutating built-ins that a plain SELECT can
89
+ * smuggle are rejected as unknown.
90
+ */
91
+ const postgresMutatingFunctions = [
92
+ // sequence / identity
93
+ "nextval", "setval",
94
+ // large objects (server filesystem writes)
95
+ "lo_import", "lo_export", "lo_creat", "lo_create", "lo_unlink", "lo_put",
96
+ // server admin / signaling
97
+ "pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
98
+ "pg_rotate_logfile", "pg_promote", "pg_log_backend_memory_contexts",
99
+ // advisory locks / notification / snapshots (state-changing)
100
+ "pg_advisory_lock", "pg_advisory_lock_shared", "pg_advisory_xact_lock",
101
+ "pg_advisory_xact_lock_shared", "pg_try_advisory_lock",
102
+ "pg_try_advisory_lock_shared", "pg_try_advisory_xact_lock",
103
+ "pg_try_advisory_xact_lock_shared", "pg_advisory_unlock",
104
+ "pg_advisory_unlock_shared", "pg_advisory_unlock_all", "pg_notify",
105
+ "pg_export_snapshot",
106
+ // replication slots
107
+ "pg_create_logical_replication_slot", "pg_drop_replication_slot",
108
+ "pg_create_physical_replication_slot",
109
+ // superuser server-side filesystem reads (exfil surface)
110
+ "pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
111
+ // resource abuse
112
+ "pg_sleep", "pg_sleep_for", "pg_sleep_until",
113
+ // remote execution via dblink
114
+ "dblink", "dblink_exec", "dblink_connect", "dblink_disconnect",
115
+ "dblink_send_query", "dblink_get_result", "dblink_open", "dblink_fetch",
116
+ ];
117
+ const mysqlMutatingFunctions = [
118
+ "sleep", "benchmark", "load_file",
119
+ "get_lock", "release_lock", "release_all_locks",
120
+ ];
121
+ /** The supported database clients. Add a new one here to expand coverage. */
122
+ export const DB_CLIENTS = [
123
+ {
124
+ binaries: ["psql"],
125
+ sqlArgExtractors: [
126
+ {
127
+ flags: ["-c", "--command"],
128
+ shortCluster: {
129
+ terminal: "c",
130
+ allowedPrefixes: ["A", "t", "P", "q", "X", "a", "b", "e", "E", "f", "h", "n", "o", "p", "s", "S", "T", "U", "v", "V", "w", "W", "x", "z"],
131
+ },
132
+ },
133
+ ],
134
+ classifySql: (sql) => isSqlReadOnly(sql, postgresMutatingFunctions),
135
+ mutatingFunctions: postgresMutatingFunctions,
136
+ },
137
+ {
138
+ binaries: ["mysql", "mariadb"],
139
+ sqlArgExtractors: [
140
+ { flags: ["-e", "--execute"] },
141
+ ],
142
+ classifySql: (sql) => isSqlReadOnly(sql, mysqlMutatingFunctions),
143
+ mutatingFunctions: mysqlMutatingFunctions,
144
+ },
145
+ ];
146
+ // --- SQL extraction (reuses shellParse — no regex over the raw command) ---
147
+ /**
148
+ * Does the flag (exact equality) match a token, including a single-dash
149
+ * short-cluster form like `-Atc` (terminal flag letter + an allowlisted set of
150
+ * prefix letters)? Only used when the token has no glued SQL payload.
151
+ */
152
+ function matchesFlagExactly(token, extractor) {
153
+ if (extractor.flags.includes(token))
154
+ return true;
155
+ if (extractor.shortCluster && token.startsWith("-") && !token.startsWith("--")) {
156
+ const { terminal, allowedPrefixes } = extractor.shortCluster;
157
+ const body = token.slice(1); // drop the leading '-'
158
+ if (!body.endsWith(terminal) || body.length < 2)
159
+ return false;
160
+ const prefixes = body.slice(0, -1).split("");
161
+ return prefixes.every((p) => allowedPrefixes.includes(p));
162
+ }
163
+ return false;
164
+ }
165
+ /**
166
+ * Resolve the SQL string for a known client binary from its tokenized argv.
167
+ * Returns `{ sql }` on success (the dequoted SQL as one string) or null when
168
+ * the argv shape can't be proven to carry inline SQL. Null → left to Guardian.
169
+ *
170
+ * Supported token shapes, in the order tried:
171
+ * 1. glued long flag `--command=SELECT …` / `--execute=SELECT …`
172
+ * 2. glued short flag `-cSELECT …` / `-eSELECT …` (2-char flag + payload)
173
+ * 3. bare flag `-c` / `--command` / `-Atc` → SQL is the NEXT token
174
+ */
175
+ export function extractSqlArg(tokens, spec) {
176
+ for (let i = 1; i < tokens.length; i++) {
177
+ const tok = tokens[i];
178
+ for (const extractor of spec.sqlArgExtractors) {
179
+ // 1. glued long flag: `--command=SELECT …`
180
+ for (const f of extractor.flags) {
181
+ if (f.startsWith("--") && tok.startsWith(f + "=")) {
182
+ const sql = tok.slice(f.length + 1);
183
+ if (sql.length > 0)
184
+ return { sql };
185
+ return null;
186
+ }
187
+ }
188
+ // 2. glued short flag: `-cSELECT …` (only for bare 2-char flags in the
189
+ // extractor, not short-clusters — a cluster like `-Atc` is a pure
190
+ // flag, not glue).
191
+ for (const f of extractor.flags) {
192
+ if (f.length === 2 && f.startsWith("-") && !f.startsWith("--")) {
193
+ if (tok.startsWith(f) && tok.length > 2) {
194
+ const sql = tok.slice(2);
195
+ if (sql.length > 0)
196
+ return { sql };
197
+ return null;
198
+ }
199
+ }
200
+ }
201
+ // 3. bare flag → SQL is the next token.
202
+ if (matchesFlagExactly(tok, extractor)) {
203
+ const v = tokens[i + 1];
204
+ if (typeof v === "string" && v.length > 0)
205
+ return { sql: v };
206
+ return null;
207
+ }
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ /**
213
+ * Top-level: should this database command be auto-allowed? Consumes the raw
214
+ * tokenized argv (tokens[0] is the binary) and returns true ONLY when the
215
+ * inline SQL was extracted AND classified read-only.
216
+ */
217
+ export function classifyDbRead(tokens) {
218
+ const binary = tokens[0];
219
+ const spec = DB_CLIENTS.find((c) => c.binaries.includes(binary));
220
+ if (!spec)
221
+ return false;
222
+ const extracted = extractSqlArg(tokens, spec);
223
+ if (!extracted)
224
+ return false;
225
+ return spec.classifySql(extracted.sql);
226
+ }
227
+ //# sourceMappingURL=dbReadPolicy.js.map
@@ -34,6 +34,8 @@
34
34
  * extension is bundled into @yagni-app/code's dist (a file copy, not a real
35
35
  * bundler), and external dependencies aren't resolvable from the bundled path.
36
36
  */
37
+ import { classifyDbRead } from "./dbReadPolicy.js";
38
+ import { forwarderLabel, forwarderTailStart, normalizePackageManagerTokens, PACKAGE_MANAGER_ALLOW_RULES, } from "./packageManagerPolicy.js";
37
39
  /**
38
40
  * Parse a shell command string into tokens and control operators.
39
41
  *
@@ -574,11 +576,13 @@ function matchRule(tokens, rule) {
574
576
  const pat = rule.pattern[i];
575
577
  const tok = tokens[i];
576
578
  if (typeof pat === "string") {
577
- if (pat !== tok)
579
+ // A trailing "*" glob lets a single pattern cover a namespace of tokens
580
+ // (e.g. "test:*" matches "test:backend", "test:file", …).
581
+ if (!tokenMatchesEntry(tok, pat))
578
582
  return false;
579
583
  }
580
584
  else {
581
- if (!pat.includes(tok))
585
+ if (!pat.some((entry) => tokenMatchesEntry(tok, entry)))
582
586
  return false;
583
587
  }
584
588
  }
@@ -633,11 +637,28 @@ function classifySegmentTokens(rawTokens, policy, opts) {
633
637
  const pathPrefixed = normalizedWord !== cmdWord;
634
638
  let tokens = pathPrefixed ? [normalizedWord, ...strippedTokens.slice(1)] : strippedTokens;
635
639
  tokens = normalizeGitTokens(tokens);
640
+ tokens = normalizePackageManagerTokens(tokens);
636
641
  const neverAllow = stripped || pathPrefixed;
637
- // xargs forwards to its argv tail: classify the tail as its own segment so
638
- // `xargs rm -rf` inherits rm's forbidden. xargs itself is never allow.
639
- if (tokens[0] === "xargs" && opts.depth < MAX_SCAN_DEPTH) {
640
- let j = 1;
642
+ // Database read promotion: a known client (psql / mysql / …) whose inline
643
+ // SQL is provably read-only is auto-allowed BEFORE rule matching. A
644
+ // non-read (write, unknown shape, -f file) result means classifyDbRead
645
+ // returns false and we fall straight through to the prompt-band `psql`/
646
+ // `mysql` rule — never to a forbidden outcome. Only consulted when the
647
+ // command is not already disqualified (neverAllow) and we are not in the
648
+ // forbidden-only danger scan.
649
+ if (!neverAllow && !opts.forbiddenOnly && classifyDbRead(tokens)) {
650
+ return {
651
+ decision: "allow",
652
+ justification: "database read-only query (SELECT/WITH, no write or mutation)",
653
+ };
654
+ }
655
+ // Forwarders (xargs / npx / <mgr> exec / <mgr> dlx) run their argv tail:
656
+ // classify the tail as its own segment so `pnpm exec rm -rf` inherits rm's
657
+ // forbidden floor and `npx tsc --noEmit` inherits tsc's allow. The forwarder
658
+ // itself is never allow; an unknown tail stays in the prompt band.
659
+ const tailStart = forwarderTailStart(tokens);
660
+ if (tailStart !== null && opts.depth < MAX_SCAN_DEPTH) {
661
+ let j = tailStart;
641
662
  while (j < tokens.length && tokens[j].startsWith("-"))
642
663
  j++;
643
664
  const tail = tokens.slice(j);
@@ -646,12 +667,18 @@ function classifySegmentTokens(rawTokens, policy, opts) {
646
667
  if (tailResult.decision === "forbidden")
647
668
  return tailResult;
648
669
  if (tailResult.decision === "allow" && !neverAllow) {
649
- return { decision: "allow", justification: "xargs forwards to a read-only command" };
670
+ return {
671
+ decision: "allow",
672
+ justification: `${forwarderLabel(tokens)} forwards to a read-only command`,
673
+ };
650
674
  }
651
675
  }
652
676
  if (opts.forbiddenOnly)
653
677
  return { decision: "allow", justification: "no forbidden match" };
654
- return { decision: "prompt", justification: "xargs executes its argument command — review the target" };
678
+ return {
679
+ decision: "prompt",
680
+ justification: `${forwarderLabel(tokens)} executes its argument command — review the target`,
681
+ };
655
682
  }
656
683
  // First match wins (rules are ordered; more specific rules come first).
657
684
  for (const rule of policy.rules) {
@@ -949,11 +976,12 @@ export const DEFAULT_EXEC_POLICY = {
949
976
  { pattern: ["pnpm", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
950
977
  { pattern: ["npm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
951
978
  { pattern: ["npm", "run", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
952
- { pattern: ["npx", "tsc", "--noEmit"], decision: "allow", justification: "typecheck only (no file writes)" },
953
- { pattern: ["npx", "tsx", "--test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
954
- { pattern: ["npx", "vitest"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
955
- { pattern: ["npx", "vitest", "run"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
956
- { pattern: ["npx", "jest"], decision: "allow", justification: "run jest tests (routine dev-loop operation)" },
979
+ // package-manager dev-loop band: dev-loop binaries (tsc --noEmit, tsx
980
+ // --test, vitest, jest) plus the test:/build:/lint: script namespaces.
981
+ // The npx/<mgr> exec/<mgr> dlx spellings reach these through tail
982
+ // forwarding (see classifySegmentTokens), so there is no separate
983
+ // "npx tsc" rule `npx tsc --noEmit` forwards to the bare tsc rule.
984
+ ...PACKAGE_MANAGER_ALLOW_RULES,
957
985
  // misc read-only commands
958
986
  { pattern: ["printenv"], decision: "allow", justification: "print environment variables (read-only)" },
959
987
  { pattern: ["npm", ["view", "info"]], decision: "allow", justification: "read package metadata from registry" },