@yagni-app/code 0.3.0 → 0.3.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 (48) hide show
  1. package/dist/cli.js +12 -0
  2. package/dist/connectClaudeCode.d.ts +77 -0
  3. package/dist/connectClaudeCode.js +228 -0
  4. package/dist/connectCodex.d.ts +75 -0
  5. package/dist/connectCodex.js +201 -0
  6. package/dist/extension/approvedPrefixes.d.ts +11 -0
  7. package/dist/extension/approvedPrefixes.js +30 -0
  8. package/dist/extension/askAdvisorTool.d.ts +18 -3
  9. package/dist/extension/askAdvisorTool.js +121 -15
  10. package/dist/extension/askYagniTool.d.ts +23 -0
  11. package/dist/extension/askYagniTool.js +42 -2
  12. package/dist/extension/branding.d.ts +11 -1
  13. package/dist/extension/branding.js +47 -7
  14. package/dist/extension/config.d.ts +12 -0
  15. package/dist/extension/config.js +2 -1
  16. package/dist/extension/execPolicy.d.ts +17 -1
  17. package/dist/extension/execPolicy.js +164 -33
  18. package/dist/extension/flywheel.d.ts +44 -0
  19. package/dist/extension/flywheel.js +53 -0
  20. package/dist/extension/footer.d.ts +8 -1
  21. package/dist/extension/footer.js +33 -19
  22. package/dist/extension/guardian.d.ts +14 -4
  23. package/dist/extension/guardian.js +35 -11
  24. package/dist/extension/index.d.ts +20 -3
  25. package/dist/extension/index.js +92 -13
  26. package/dist/extension/mineBeat.d.ts +95 -0
  27. package/dist/extension/mineBeat.js +193 -0
  28. package/dist/extension/permission.d.ts +1 -0
  29. package/dist/extension/permission.js +23 -18
  30. package/dist/extension/pipeline/goCommand.js +6 -4
  31. package/dist/extension/pipeline/personas.js +1 -1
  32. package/dist/extension/pipeline/resilience.d.ts +2 -1
  33. package/dist/extension/pipeline/resilience.js +21 -2
  34. package/dist/extension/pipeline/runRegistry.d.ts +9 -1
  35. package/dist/extension/pipeline/runRegistry.js +22 -1
  36. package/dist/extension/recordDecisionTool.d.ts +8 -0
  37. package/dist/extension/recordDecisionTool.js +24 -0
  38. package/dist/extension/subagents.d.ts +7 -1
  39. package/dist/extension/subagents.js +60 -5
  40. package/dist/extension/todos.d.ts +28 -1
  41. package/dist/extension/todos.js +76 -1
  42. package/dist/extension/ultra.d.ts +27 -0
  43. package/dist/extension/ultra.js +76 -0
  44. package/dist/login.d.ts +4 -2
  45. package/dist/login.js +19 -4
  46. package/dist/token.d.ts +25 -0
  47. package/dist/token.js +45 -0
  48. package/package.json +3 -2
@@ -5,6 +5,7 @@ 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
7
  import { type CatalogResult, type ContextBrief } from "./config.js";
8
+ import { maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, type MineBeatMarkers } from "./mineBeat.js";
8
9
  /**
9
10
  * YAGNI Code extension entry point.
10
11
  *
@@ -36,7 +37,21 @@ export interface RegisterYagniDeps {
36
37
  baseUrl: string;
37
38
  getToken: () => string | undefined;
38
39
  fetchImpl?: typeof fetch;
40
+ repo?: string;
39
41
  }) => Promise<ContextBrief | null>;
42
+ /**
43
+ * The session repo (`owner/name`), resolved from the origin remote before
44
+ * the boot brief fetch so `/context` can carry `repoDecisionCount` for the
45
+ * mining beat. Injectable (no git in tests); undefined outside a repo.
46
+ */
47
+ resolveRepoFullName?: (cwd: string) => string | undefined;
48
+ /**
49
+ * The opt-in seeding beat itself (Run 7). Injectable so tests assert it
50
+ * fires only on a startup with an explicitly-zero repo ledger.
51
+ */
52
+ offerMiningBeat?: typeof defaultMaybeOfferMiningBeat;
53
+ /** The beat's once-per-repo marker store. Injectable (no disk in tests). */
54
+ mineBeatMarkers?: MineBeatMarkers;
40
55
  /**
41
56
  * Workspace MCP server list seam (YAG-446). The default hits
42
57
  * `/api/yagni-code/mcp/servers` and is fail-soft: older backends and
@@ -122,7 +137,9 @@ export type { RecordDecisionParams } from "./recordDecisionTool.js";
122
137
  export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
123
138
  export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
124
139
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
125
- export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
140
+ export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
141
+ export { createUltraHolder, registerUltraCommand } from "./ultra.js";
142
+ export type { UltraHolder } from "./ultra.js";
126
143
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
127
144
  export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
128
145
  export { buildYagniProvider } from "./provider.js";
@@ -139,9 +156,9 @@ export type { VerifyCommand, VerifyOutcome } from "./pipeline/verify.js";
139
156
  export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
140
157
  export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js";
141
158
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
142
- export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
159
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
143
160
  export type { SubagentDef, SubagentSource } from "./subagents.js";
144
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
161
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
145
162
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
146
163
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
147
164
  export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
@@ -14,13 +14,13 @@ import { registerCmuxBridge } from "./cmux/index.js";
14
14
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
15
15
  import { makeRecordDecisionTool } from "./recordDecisionTool.js";
16
16
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
17
- import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER } from "./branding.js";
17
+ import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.js";
18
18
  import { claudeRulesSection } from "./claudeRules.js";
19
19
  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 { createYagniFooterFactory, formatCwd } from "./footer.js";
23
+ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
24
24
  import { RerouteNotifier } from "./rerouteNotice.js";
25
25
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
26
26
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
@@ -29,6 +29,7 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
29
29
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
30
30
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
31
31
  import { registerSubagents } from "./subagents.js";
32
+ import { createUltraHolder, registerUltraCommand } from "./ultra.js";
32
33
  import { registerTodos } from "./todos.js";
33
34
  import { registerDecisionCommands } from "./decisions.js";
34
35
  import { makeDecisionCapture } from "./decisionCapture.js";
@@ -40,6 +41,8 @@ import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
40
41
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
41
42
  import { buildYagniProvider } from "./provider.js";
42
43
  import { registerChipEditor } from "./chipEditor.js";
44
+ import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
45
+ import { makeFlywheelState } from "./flywheel.js";
43
46
  function isEvalMode(env = process.env) {
44
47
  return env.YAGNI_CODE_EVAL_MODE === "1";
45
48
  }
@@ -143,7 +146,11 @@ export async function registerYagni(pi, deps = {}) {
143
146
  // child, a subagent, or an advisor consult).
144
147
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
145
148
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
146
- pi.registerTool(makeAskYagniTool(toolOpts));
149
+ // One flywheel state per session, shared by ask_yagni (caps how many
150
+ // no-position record suggestions reach the model) and record_decision
151
+ // (flywheel-attributed records send dedupe: true). Run 7.
152
+ const flywheelState = makeFlywheelState();
153
+ pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
147
154
  // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
148
155
  // workspace tracker, attributed to the developer via per-user credentials.
149
156
  if (!evalMode) {
@@ -171,7 +178,7 @@ export async function registerYagni(pi, deps = {}) {
171
178
  pi.registerTool(makeRecordEngineeringContextTool(toolOpts));
172
179
  // The capture half of the judgment loop: bank a product-intent decision so
173
180
  // ask_yagni answers the same question next time instead of interrupting a human.
174
- pi.registerTool(makeRecordDecisionTool(toolOpts));
181
+ pi.registerTool(makeRecordDecisionTool({ ...toolOpts, flywheel: flywheelState }));
175
182
  }
176
183
  // The visible checklist for multi-step work: the todo_write tool, its
177
184
  // above-editor widget, and /todos. Branch-replayed, so forks and resumes
@@ -185,7 +192,12 @@ export async function registerYagni(pi, deps = {}) {
185
192
  // The general subagent tool: delegate self-contained tasks (optionally in
186
193
  // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
187
194
  // riding the /go pipeline's child runner. /agents lists what's available.
188
- registerSubagents(pi);
195
+ // Ultra mode (/ultra) is an explicit orchestration dial: it swaps the
196
+ // driver's delegation identity for the diamond directive (see the
197
+ // before_agent_start handler below) and widens the tool's fan-out ceiling.
198
+ const ultraHolder = createUltraHolder();
199
+ registerSubagents(pi, { isUltra: () => ultraHolder.get() });
200
+ registerUltraCommand(pi, ultraHolder);
189
201
  // Shared fetch timeout for the small, interactive display-path reads below
190
202
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
191
203
  // 5s, not the 10s other boot/grounding fetches use, because the user is
@@ -238,6 +250,7 @@ export async function registerYagni(pi, deps = {}) {
238
250
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
239
251
  // holds them, review mode confirms them.
240
252
  const modeHolder = createModeHolder();
253
+ const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
241
254
  const guardianState = makeGuardianState();
242
255
  // Disabled by the local env override OR the workspace kill switch
243
256
  // (yagni_code.guardian, read from the catalog response at launch). The env
@@ -262,7 +275,9 @@ export async function registerYagni(pi, deps = {}) {
262
275
  catch { /* logging must never break the session */ }
263
276
  };
264
277
  // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
265
- // at startup (grants added by other concurrent sessions appear next launch).
278
+ // at startup (grants added by other concurrent sessions appear next launch
279
+ // the startup load is the trust boundary; live reload was reviewed and
280
+ // rejected as a same-session self-authorization path, PR #1698).
266
281
  const sessionGrants = evalMode ? [] : loadGrants();
267
282
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
268
283
  registerPermissionGate(pi, {
@@ -452,11 +467,27 @@ export async function registerYagni(pi, deps = {}) {
452
467
  // must never block the agent), then inject it into the system prompt so the
453
468
  // very first turn already knows how this company and codebase work.
454
469
  const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
470
+ // Resolved BEFORE the brief fetch so /context can answer repoDecisionCount
471
+ // in the same round-trip (the mining beat's signal). Best-effort: undefined
472
+ // outside a git repo or without an origin remote.
473
+ const resolveRepoFn = deps.resolveRepoFullName ?? ((cwd) => defaultMineBeatGit.repoFullName(cwd));
474
+ let sessionRepo;
475
+ try {
476
+ sessionRepo = evalMode ? undefined : resolveRepoFn(process.cwd());
477
+ }
478
+ catch {
479
+ sessionRepo = undefined;
480
+ }
455
481
  let contextBrief;
456
482
  let briefResult = null;
457
483
  if (!evalMode) {
458
484
  try {
459
- briefResult = await fetchBrief({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
485
+ briefResult = await fetchBrief({
486
+ baseUrl,
487
+ getToken: getTokenFn,
488
+ fetchImpl: authedFetch,
489
+ repo: sessionRepo,
490
+ });
460
491
  contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
461
492
  }
462
493
  catch {
@@ -517,13 +548,19 @@ export async function registerYagni(pi, deps = {}) {
517
548
  // assembled prompt passes through byte-exact (no rewrite, no brief
518
549
  // injection, no rules section, no delegation identity).
519
550
  const noBranding = brandingDisabled(env);
520
- const identity = isDriverCaller(env) ? YAGNI_IDENTITY_DRIVER : undefined;
551
+ // Resolved per turn (not once at activation): /ultra can flip the driver
552
+ // between the delegation and diamond identities mid-session.
553
+ const isDriver = isDriverCaller(env);
521
554
  pi.on("before_agent_start", (event) => noBranding
522
555
  ? undefined
523
556
  : {
524
557
  systemPrompt: brandSystemPrompt(event.systemPrompt, {
525
558
  contextBrief,
526
- identity,
559
+ identity: isDriver
560
+ ? ultraHolder.get()
561
+ ? YAGNI_IDENTITY_ULTRA
562
+ : YAGNI_IDENTITY_DRIVER
563
+ : undefined,
527
564
  rulesSection,
528
565
  }),
529
566
  });
@@ -681,7 +718,15 @@ export async function registerYagni(pi, deps = {}) {
681
718
  // context % on line 2, and extension statuses (brand, todos, mode) on
682
719
  // line 3. The factory captures ctx so the footer can read session data
683
720
  // (token stats, context usage) that isn't on the footerData provider.
684
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
721
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
722
+ ctx.ui?.onTerminalInput?.((data) => {
723
+ if (isShiftTab(data)) {
724
+ modeHolder.set(cyclePermissionMode(modeHolder.get()));
725
+ footerInvalidateHandle.requestRender();
726
+ return { consume: true };
727
+ }
728
+ return undefined;
729
+ });
685
730
  // Label the collapsed chain-of-thought line so users know it is reasoning
686
731
  // and how to reveal the full trace. Harmless when reasoning is expanded
687
732
  // (the label only shows on hidden thinking blocks). Fails closed to pi's
@@ -755,6 +800,38 @@ export async function registerYagni(pi, deps = {}) {
755
800
  // The init pass is best-effort; a failure never blocks the session.
756
801
  }
757
802
  }
803
+ // Run 7: the opt-in repo seeding beat. Fires only on a genuine startup
804
+ // when /context reported an explicitly-empty repo-scoped ledger, once per
805
+ // repo (marker store). Fully fail-soft — a beat failure never blocks the
806
+ // session.
807
+ if (event.reason === "startup" && !evalMode) {
808
+ try {
809
+ const offerBeat = deps.offerMiningBeat ?? defaultMaybeOfferMiningBeat;
810
+ const markers = deps.mineBeatMarkers ?? fileMineBeatMarkers(codeStateHome(null, env));
811
+ await offerBeat(ctx, {
812
+ repoFullName: sessionRepo,
813
+ repoDecisionCount: briefResult?.repoDecisionCount,
814
+ cwd: process.cwd(),
815
+ baseUrl,
816
+ getToken: getTokenFn,
817
+ fetchImpl: authedFetch,
818
+ markers,
819
+ });
820
+ }
821
+ catch {
822
+ // Seeding is best-effort; a failure never blocks the session.
823
+ }
824
+ }
825
+ });
826
+ pi.on("tool_result", (event) => {
827
+ if (event.toolName !== "bash")
828
+ return;
829
+ const command = typeof event.input?.command === "string"
830
+ ? event.input.command
831
+ : "";
832
+ if (GIT_MUTATING_PATTERN.test(command)) {
833
+ footerInvalidateHandle.invalidateGit();
834
+ }
758
835
  });
759
836
  }
760
837
  export default async function (pi) {
@@ -781,7 +858,9 @@ export { recordDecision } from "./recordDecisionTool.js";
781
858
  export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
782
859
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
783
860
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
784
- export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
861
+ export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
862
+ // Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
863
+ export { createUltraHolder, registerUltraCommand } from "./ultra.js";
785
864
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
786
865
  export { buildYagniProvider } from "./provider.js";
787
866
  export { registerGoCommand } from "./pipeline/goCommand.js";
@@ -796,9 +875,9 @@ export { buildVerifyEnv, detectVerifyCommand, makeRunVerify, parseVerifyFailures
796
875
  export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
797
876
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
798
877
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
799
- export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
878
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
800
879
  // The session todo checklist: todo_write tool, widget renderer, /todos.
801
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
880
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
802
881
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
803
882
  // the session bless-with-remember capture hook.
804
883
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The opt-in repo seeding beat (org plane, Run 7).
3
+ *
4
+ * On the first interactive session in a repo whose repo-scoped decision
5
+ * ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
6
+ * Code offers — once — to read the repo's own decision record and seed the
7
+ * ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
8
+ * One keystroke, then the backend extracts, dedupes, and banks at most 12
9
+ * `repo_mined` asserted rows, and the beat prints the banked count plus the
10
+ * Library review deep link.
11
+ *
12
+ * Never automatic, never a nag: a decline writes a per-repo marker so the
13
+ * beat does not recur; an accepted pass marks the repo only after the server
14
+ * confirms (a transport failure leaves the offer available next session).
15
+ * The corpus is collected at git HEAD (same echo-chamber guard as
16
+ * repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
17
+ * docs/*.md, and recent merge subjects — bounded client-side to the same
18
+ * limits the backend enforces.
19
+ */
20
+ /**
21
+ * Client-side bounds on the mining corpus — mirror of the backend's
22
+ * MINE_INPUT_LIMITS so nothing is silently truncated server-side.
23
+ */
24
+ export declare const MINE_BEAT_LIMITS: {
25
+ readonly maxDocs: 40;
26
+ readonly maxPathChars: 300;
27
+ readonly maxExcerptChars: 8000;
28
+ readonly maxCommitSubjects: 200;
29
+ readonly maxSubjectChars: 200;
30
+ };
31
+ /** One document offered to the mining pass. */
32
+ export interface MineBeatDoc {
33
+ path: string;
34
+ excerpt: string;
35
+ }
36
+ /** Injectable git seam (default: execFileSync; every call fail-soft). */
37
+ export interface MineBeatGit {
38
+ /** `owner/name` from the origin remote, or undefined outside a repo. */
39
+ repoFullName: (cwd: string) => string | undefined;
40
+ /** Tracked paths at HEAD, or null when cwd is not a usable git tree. */
41
+ listAtHead: (cwd: string) => string[] | null;
42
+ /** One file's HEAD content, or null when unreadable. */
43
+ readAtHead: (cwd: string, rel: string) => string | null;
44
+ /** Recent merge-commit subjects (fallback: plain subjects on squash repos). */
45
+ mergeSubjects: (cwd: string, max: number) => string[];
46
+ }
47
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
48
+ export declare function parseRepoFullName(remote: string | null | undefined): string | undefined;
49
+ export declare const defaultMineBeatGit: MineBeatGit;
50
+ /** Is this HEAD-tracked path part of the repo's decision record? */
51
+ export declare function isMineCandidate(rel: string): boolean;
52
+ /** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
53
+ export declare function collectMineCorpus(cwd: string, gitImpl?: MineBeatGit): {
54
+ docs: MineBeatDoc[];
55
+ commitSubjects: string[];
56
+ };
57
+ /** Injectable marker-store seam (default: a JSON file per repo on disk). */
58
+ export interface MineBeatMarkers {
59
+ has: (repoFullName: string) => boolean;
60
+ write: (repoFullName: string, status: "declined" | "seeded") => void;
61
+ }
62
+ /** Filesystem path of one repo's beat marker under the state home. */
63
+ export declare function mineMarkerPath(stateHome: string, repoFullName: string): string;
64
+ export declare function fileMineBeatMarkers(stateHome: string): MineBeatMarkers;
65
+ /** The subset of the session-start ctx the beat needs. */
66
+ export interface MineBeatUi {
67
+ hasUI: boolean;
68
+ ui: {
69
+ confirm?: (title: string, message: string) => Promise<boolean>;
70
+ notify: (message: string, level: "info" | "error") => void;
71
+ };
72
+ }
73
+ export interface OfferMiningBeatOptions {
74
+ repoFullName: string | undefined;
75
+ /** From `/context?repo=`; the beat fires ONLY on an explicit 0. */
76
+ repoDecisionCount: number | undefined;
77
+ cwd: string;
78
+ baseUrl: string;
79
+ getToken: () => string | undefined;
80
+ fetchImpl?: typeof fetch;
81
+ markers: MineBeatMarkers;
82
+ gitImpl?: MineBeatGit;
83
+ collect?: typeof collectMineCorpus;
84
+ }
85
+ export interface MiningBeatOutcome {
86
+ offered: boolean;
87
+ accepted?: boolean;
88
+ banked?: number;
89
+ }
90
+ /**
91
+ * Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
92
+ * throughout: any error surfaces as a notify at most, never a broken session.
93
+ */
94
+ export declare function maybeOfferMiningBeat(ctx: MineBeatUi, opts: OfferMiningBeatOptions): Promise<MiningBeatOutcome>;
95
+ //# sourceMappingURL=mineBeat.d.ts.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The opt-in repo seeding beat (org plane, Run 7).
3
+ *
4
+ * On the first interactive session in a repo whose repo-scoped decision
5
+ * ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
6
+ * Code offers — once — to read the repo's own decision record and seed the
7
+ * ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
8
+ * One keystroke, then the backend extracts, dedupes, and banks at most 12
9
+ * `repo_mined` asserted rows, and the beat prints the banked count plus the
10
+ * Library review deep link.
11
+ *
12
+ * Never automatic, never a nag: a decline writes a per-repo marker so the
13
+ * beat does not recur; an accepted pass marks the repo only after the server
14
+ * confirms (a transport failure leaves the offer available next session).
15
+ * The corpus is collected at git HEAD (same echo-chamber guard as
16
+ * repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
17
+ * docs/*.md, and recent merge subjects — bounded client-side to the same
18
+ * limits the backend enforces.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import * as fs from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
24
+ /**
25
+ * Client-side bounds on the mining corpus — mirror of the backend's
26
+ * MINE_INPUT_LIMITS so nothing is silently truncated server-side.
27
+ */
28
+ export const MINE_BEAT_LIMITS = {
29
+ maxDocs: 40,
30
+ maxPathChars: 300,
31
+ maxExcerptChars: 8_000,
32
+ maxCommitSubjects: 200,
33
+ maxSubjectChars: 200,
34
+ };
35
+ const GIT_OPTS = {
36
+ encoding: "utf8",
37
+ maxBuffer: 16 * 1024 * 1024,
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ };
40
+ function git(cwd, args) {
41
+ try {
42
+ return execFileSync("git", args, { cwd, ...GIT_OPTS });
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
49
+ export function parseRepoFullName(remote) {
50
+ if (!remote)
51
+ return undefined;
52
+ const m = remote.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
53
+ return m ? m[1] : undefined;
54
+ }
55
+ export const defaultMineBeatGit = {
56
+ repoFullName: (cwd) => parseRepoFullName(git(cwd, ["config", "--get", "remote.origin.url"])),
57
+ listAtHead: (cwd) => {
58
+ const out = git(cwd, ["ls-tree", "-r", "--name-only", "HEAD"]);
59
+ return out === null ? null : out.split("\n").filter(Boolean);
60
+ },
61
+ readAtHead: (cwd, rel) => git(cwd, ["show", `HEAD:${rel}`]),
62
+ mergeSubjects: (cwd, max) => {
63
+ const merges = git(cwd, ["log", "--merges", `-n`, String(max), "--pretty=%s"]);
64
+ const chosen = merges && merges.trim().length > 0
65
+ ? merges
66
+ : // Squash-merge repos have no merge commits; plain subjects still
67
+ // carry the shipped-work trail. No further heuristics (spec flag).
68
+ git(cwd, ["log", `-n`, String(max), "--pretty=%s"]);
69
+ return (chosen ?? "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, max);
70
+ },
71
+ };
72
+ /** Is this HEAD-tracked path part of the repo's decision record? */
73
+ export function isMineCandidate(rel) {
74
+ if (/^(?:docs\/)?adrs?\/[^/]+\.md$/i.test(rel))
75
+ return true;
76
+ if (/^(?:AGENTS|CLAUDE|CONTRIBUTING|CONTEXT|ARCHITECTURE)\.md$/i.test(rel))
77
+ return true;
78
+ if (/^docs\/[^/]+\.md$/i.test(rel))
79
+ return true;
80
+ return false;
81
+ }
82
+ /** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
83
+ export function collectMineCorpus(cwd, gitImpl = defaultMineBeatGit) {
84
+ const tracked = gitImpl.listAtHead(cwd);
85
+ if (tracked === null)
86
+ return { docs: [], commitSubjects: [] };
87
+ const docs = [];
88
+ for (const rel of tracked) {
89
+ if (docs.length >= MINE_BEAT_LIMITS.maxDocs)
90
+ break;
91
+ if (rel.length > MINE_BEAT_LIMITS.maxPathChars)
92
+ continue;
93
+ if (!isMineCandidate(rel))
94
+ continue;
95
+ const body = gitImpl.readAtHead(cwd, rel);
96
+ if (body === null || body.trim().length === 0)
97
+ continue;
98
+ docs.push({ path: rel, excerpt: body.trim().slice(0, MINE_BEAT_LIMITS.maxExcerptChars) });
99
+ }
100
+ const commitSubjects = gitImpl
101
+ .mergeSubjects(cwd, MINE_BEAT_LIMITS.maxCommitSubjects)
102
+ .map((s) => s.slice(0, MINE_BEAT_LIMITS.maxSubjectChars));
103
+ return { docs, commitSubjects };
104
+ }
105
+ /** Filesystem path of one repo's beat marker under the state home. */
106
+ export function mineMarkerPath(stateHome, repoFullName) {
107
+ return join(stateHome, "mine-beat", `${repoFullName.replace(/[/\\]/g, "__")}.json`);
108
+ }
109
+ export function fileMineBeatMarkers(stateHome) {
110
+ return {
111
+ has: (repo) => {
112
+ try {
113
+ return fs.existsSync(mineMarkerPath(stateHome, repo));
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ },
119
+ write: (repo, status) => {
120
+ try {
121
+ const p = mineMarkerPath(stateHome, repo);
122
+ fs.mkdirSync(dirname(p), { recursive: true });
123
+ fs.writeFileSync(p, JSON.stringify({ status, at: new Date().toISOString() }), "utf8");
124
+ }
125
+ catch {
126
+ // A marker failure must never break the session; worst case the beat
127
+ // asks once more next launch.
128
+ }
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
134
+ * throughout: any error surfaces as a notify at most, never a broken session.
135
+ */
136
+ export async function maybeOfferMiningBeat(ctx, opts) {
137
+ const { repoFullName } = opts;
138
+ // Only an EXPLICIT zero fires the beat: an absent count means an older
139
+ // backend or a count failure, and a nag on either would be wrong.
140
+ if (!repoFullName || opts.repoDecisionCount !== 0)
141
+ return { offered: false };
142
+ if (opts.markers.has(repoFullName))
143
+ return { offered: false };
144
+ if (!ctx.hasUI || typeof ctx.ui.confirm !== "function")
145
+ return { offered: false };
146
+ const collect = opts.collect ?? collectMineCorpus;
147
+ const corpus = collect(opts.cwd, opts.gitImpl ?? defaultMineBeatGit);
148
+ // Nothing to mine yet: skip WITHOUT a marker, so a later checkout that
149
+ // gains docs still gets its one offer.
150
+ if (corpus.docs.length === 0 && corpus.commitSubjects.length === 0) {
151
+ return { offered: false };
152
+ }
153
+ const accepted = await ctx.ui.confirm("Seed the decision ledger?", `Read this repo's ADRs and docs and bank up to 12 candidate decisions for ${repoFullName}? You review every one in the Library before it counts as confirmed.`);
154
+ if (!accepted) {
155
+ opts.markers.write(repoFullName, "declined");
156
+ return { offered: true, accepted: false };
157
+ }
158
+ try {
159
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/decisions/mine`, {
160
+ method: "POST",
161
+ headers: {
162
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
163
+ "content-type": "application/json",
164
+ },
165
+ body: JSON.stringify({
166
+ repo: repoFullName,
167
+ docs: corpus.docs,
168
+ commitSubjects: corpus.commitSubjects,
169
+ // Stable per repo: the write-spool replay contract makes a retry of
170
+ // the same pass idempotent server-side.
171
+ idempotencyKey: `mine:${repoFullName}`,
172
+ }),
173
+ }, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
174
+ if (!res.ok) {
175
+ // No marker: the offer stays available next session.
176
+ ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
177
+ return { offered: true, accepted: true };
178
+ }
179
+ const body = (await res.json());
180
+ opts.markers.write(repoFullName, "seeded");
181
+ if (body.alreadyMined)
182
+ return { offered: true, accepted: true, banked: 0 };
183
+ const banked = typeof body.banked === "number" ? body.banked : 0;
184
+ const noun = banked === 1 ? "decision" : "decisions";
185
+ ctx.ui.notify(`Banked ${banked} ${noun} read from this repo's docs. Review them: ${opts.baseUrl}/knowledge/decisions`, "info");
186
+ return { offered: true, accepted: true, banked };
187
+ }
188
+ catch {
189
+ ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
190
+ return { offered: true, accepted: true };
191
+ }
192
+ }
193
+ //# sourceMappingURL=mineBeat.js.map
@@ -41,6 +41,7 @@ export type PermissionMode = "auto" | "plan" | "review";
41
41
  export interface ModeHolder {
42
42
  get(): PermissionMode;
43
43
  set(m: PermissionMode): void;
44
+ onSet(fn: (m: PermissionMode) => void): void;
44
45
  }
45
46
  export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
46
47
  /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
@@ -33,9 +33,15 @@ import { isDebug } from "./diagnostics.js";
33
33
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
34
34
  export function createModeHolder(initial = "auto") {
35
35
  let current = initial;
36
+ const listeners = new Set();
36
37
  return {
37
38
  get: () => current,
38
- set: (m) => { current = m; },
39
+ set: (m) => {
40
+ current = m;
41
+ for (const fn of listeners)
42
+ fn(m);
43
+ },
44
+ onSet: (fn) => { listeners.add(fn); },
39
45
  };
40
46
  }
41
47
  export const DEFAULT_PERMISSION_POLICY = {
@@ -199,11 +205,6 @@ export function filterStaleModeContext(messages, currentMode) {
199
205
  }
200
206
  /** Legacy alias — the original plan-mode filter name. */
201
207
  export const filterStalePlanContext = filterStaleModeContext;
202
- const MODE_STATUS = {
203
- auto: undefined,
204
- plan: "⏸ plan",
205
- review: "✓ review",
206
- };
207
208
  const MODE_COPY = {
208
209
  auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
209
210
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
@@ -257,6 +258,11 @@ export function registerPermissionGate(pi, deps = {}) {
257
258
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
258
259
  let mode = deps.mode ?? "auto";
259
260
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
261
+ deps.modeHolder?.onSet((m) => {
262
+ if (m !== mode)
263
+ approvedCommands.clear();
264
+ mode = m;
265
+ });
260
266
  // The session bless store is created lazily on the first tool_call (it needs
261
267
  // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
262
268
  // injected its own isBlessed (e.g. a test policy) — that always wins.
@@ -279,6 +285,11 @@ export function registerPermissionGate(pi, deps = {}) {
279
285
  const guardianTier = deps.guardianTier;
280
286
  // --- YAG-510 gate state ---
281
287
  // Grants: in-memory list seeded from deps, appended on "don't ask again".
288
+ // Deliberately NOT live-reloaded from disk: auto mode can write files, so a
289
+ // mid-session re-read of rules.json would let the agent (or a prompt
290
+ // injection) author its own grants and self-authorize within the same
291
+ // session. New grants from concurrent sessions apply at next launch — the
292
+ // startup load is the trust boundary (PR #1698 review).
282
293
  const grants = [...(deps.grants ?? [])];
283
294
  // Keyed by cwd: a session can change working directory (cd, /go worktrees),
284
295
  // and a repoKey memoized from the first cwd would let repo-A grants match
@@ -410,12 +421,13 @@ export function registerPermissionGate(pi, deps = {}) {
410
421
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
411
422
  const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
412
423
  if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
413
- // Session consult cap. Review mode falls through to its ordinary
414
- // confirm (no LLM cost); auto blocks.
424
+ // Sliding-window consult cap (capacity recovers as old reviews age
425
+ // out a long-lived session is never bricked). Review mode falls
426
+ // through to its ordinary confirm (no LLM cost); auto blocks.
415
427
  if (modeAtEntry === "auto") {
416
428
  if (ctx?.hasUI)
417
- ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} this session).`, "warning");
418
- return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} this session). Switch to /mode review to approve manually.` };
429
+ ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
430
+ return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
419
431
  }
420
432
  // fall through to decision.confirm below
421
433
  }
@@ -447,7 +459,7 @@ export function registerPermissionGate(pi, deps = {}) {
447
459
  }
448
460
  // Show the reviewing chip.
449
461
  if (ctx?.hasUI)
450
- ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
462
+ ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
451
463
  const startMs = Date.now();
452
464
  let reviewResult;
453
465
  try {
@@ -740,13 +752,6 @@ export function registerPermissionGate(pi, deps = {}) {
740
752
  });
741
753
  const paintMode = (ctx) => {
742
754
  deps.modeHolder?.set(mode);
743
- try {
744
- if (ctx.hasUI)
745
- ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
746
- }
747
- catch {
748
- // The chip is chrome; never let it break /mode.
749
- }
750
755
  };
751
756
  pi.registerCommand("mode", {
752
757
  description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",