@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.
- package/dist/cli.js +12 -0
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/extension/approvedPrefixes.d.ts +11 -0
- package/dist/extension/approvedPrefixes.js +30 -0
- package/dist/extension/askAdvisorTool.d.ts +18 -3
- package/dist/extension/askAdvisorTool.js +121 -15
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +42 -2
- package/dist/extension/branding.d.ts +11 -1
- package/dist/extension/branding.js +47 -7
- package/dist/extension/config.d.ts +12 -0
- package/dist/extension/config.js +2 -1
- package/dist/extension/execPolicy.d.ts +17 -1
- package/dist/extension/execPolicy.js +164 -33
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +8 -1
- package/dist/extension/footer.js +33 -19
- package/dist/extension/guardian.d.ts +14 -4
- package/dist/extension/guardian.js +35 -11
- package/dist/extension/index.d.ts +20 -3
- package/dist/extension/index.js +92 -13
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +1 -0
- package/dist/extension/permission.js +23 -18
- package/dist/extension/pipeline/goCommand.js +6 -4
- package/dist/extension/pipeline/personas.js +1 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +9 -1
- package/dist/extension/pipeline/runRegistry.js +22 -1
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/subagents.d.ts +7 -1
- package/dist/extension/subagents.js +60 -5
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- 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";
|
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
//
|
|
414
|
-
//
|
|
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}
|
|
418
|
-
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews}
|
|
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", "
|
|
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.",
|