@yagni-app/code 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +135 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +92 -0
- package/dist/extension/approvedPrefixes.js +252 -0
- package/dist/extension/askAdvisorTool.d.ts +2 -2
- package/dist/extension/askAdvisorTool.js +5 -5
- package/dist/extension/askYagniTool.js +49 -0
- package/dist/extension/branding.d.ts +24 -3
- package/dist/extension/branding.js +71 -10
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +32 -1
- package/dist/extension/config.js +36 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +119 -0
- package/dist/extension/execPolicy.js +805 -0
- package/dist/extension/footer.d.ts +111 -0
- package/dist/extension/footer.js +294 -0
- package/dist/extension/guardian.d.ts +129 -0
- package/dist/extension/guardian.js +213 -0
- package/dist/extension/index.d.ts +15 -4
- package/dist/extension/index.js +250 -24
- package/dist/extension/permission.d.ts +123 -10
- package/dist/extension/permission.js +586 -40
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +35 -6
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +25 -0
- package/dist/extension/pipeline/runRegistry.d.ts +14 -0
- package/dist/extension/pipeline/runRegistry.js +35 -0
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +4 -7
- package/dist/extension/subagents.js +103 -33
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/package.json +5 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
package/dist/extension/index.js
CHANGED
|
@@ -1,34 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
5
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
6
|
+
import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./approvedPrefixes.js";
|
|
7
|
+
import { redactCommand } from "./redact.js";
|
|
8
|
+
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
|
|
5
9
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
6
|
-
import { registerBoostCommand } from "./boostCommand.js";
|
|
7
10
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
11
|
+
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
8
12
|
import { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
13
|
+
import { registerCmuxBridge } from "./cmux/index.js";
|
|
9
14
|
import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
10
15
|
import { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
11
16
|
import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
|
|
12
17
|
import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER } from "./branding.js";
|
|
13
|
-
import {
|
|
18
|
+
import { claudeRulesSection } from "./claudeRules.js";
|
|
14
19
|
import { registerCostCommand } from "./costHud.js";
|
|
15
20
|
import { isDebug } from "./diagnostics.js";
|
|
16
21
|
import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
|
|
17
22
|
import { codeStateHome } from "./stateHome.js";
|
|
23
|
+
import { createYagniFooterFactory, formatCwd } from "./footer.js";
|
|
18
24
|
import { RerouteNotifier } from "./rerouteNotice.js";
|
|
19
25
|
import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
20
26
|
import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
|
|
21
27
|
import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
|
|
22
28
|
import { registerGoCommand } from "./pipeline/goCommand.js";
|
|
23
29
|
import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
24
|
-
import { DEFAULT_PERMISSION_POLICY, registerPermissionGate } from "./permission.js";
|
|
30
|
+
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
|
|
25
31
|
import { registerSubagents } from "./subagents.js";
|
|
26
32
|
import { registerTodos } from "./todos.js";
|
|
27
33
|
import { registerDecisionCommands } from "./decisions.js";
|
|
28
34
|
import { makeDecisionCapture } from "./decisionCapture.js";
|
|
29
35
|
import { registerAmbientRecall } from "./recall.js";
|
|
30
36
|
import { resilientFetch } from "./resilientFetch.js";
|
|
31
|
-
import { installUncaughtExceptionMonitor } from "./crashReport.js";
|
|
37
|
+
import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
|
|
32
38
|
import { flushSpool as defaultFlushSpool } from "./spool.js";
|
|
33
39
|
import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
|
|
34
40
|
import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
|
|
@@ -111,7 +117,26 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
111
117
|
if (!evalMode) {
|
|
112
118
|
installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
|
|
113
119
|
}
|
|
114
|
-
|
|
120
|
+
// YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
|
|
121
|
+
// Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
|
|
122
|
+
// so auth failures are visible in Sentry/backend logs. Gated on !evalMode
|
|
123
|
+
// like every other external side effect; injectable for tests.
|
|
124
|
+
const authReporter = deps.authReporter ??
|
|
125
|
+
(!evalMode
|
|
126
|
+
? makeCrashReporter({ baseUrl, getToken: getTokenFn, fetchImpl: deps.fetchImpl, env: deps.env })
|
|
127
|
+
: async () => { });
|
|
128
|
+
// YAG-500 Fix A+C: the model-path 401 recovery outcome, set by the
|
|
129
|
+
// message_end handler so it can produce the right user-facing message. The
|
|
130
|
+
// after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
|
|
131
|
+
// before onResponse is reached), so message_end is the only seam.
|
|
132
|
+
let lastAuthRecovery = null;
|
|
133
|
+
const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
134
|
+
// Lock the interactive session to the `advanced` tier only. The backend
|
|
135
|
+
// catalog returns all tiers, but only `advanced` is registered with the
|
|
136
|
+
// `yagni` provider, so /model and Ctrl+P show a single entry. Child
|
|
137
|
+
// processes (/go, subagents, advisor) fetch their own catalog and register
|
|
138
|
+
// their own provider, so they are unaffected by this filter.
|
|
139
|
+
const catalog = fullCatalog.filter((m) => m.id === "advanced");
|
|
115
140
|
// YAG-471: the driver's own completions carry attribution headers read from
|
|
116
141
|
// this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
|
|
117
142
|
// defaults to "driver" when unset, i.e. every session that is not a /go
|
|
@@ -119,11 +144,17 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
119
144
|
pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
|
|
120
145
|
const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
|
|
121
146
|
pi.registerTool(makeAskYagniTool(toolOpts));
|
|
122
|
-
//
|
|
147
|
+
// Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
|
|
148
|
+
// workspace tracker, attributed to the developer via per-user credentials.
|
|
149
|
+
if (!evalMode) {
|
|
150
|
+
pi.registerTool(makeFileTicketTool(toolOpts));
|
|
151
|
+
pi.registerTool(makeUpdateTicketStatusTool(toolOpts));
|
|
152
|
+
}
|
|
153
|
+
// The peak-tier escalation for Advanced sessions (YAG-380). Registered
|
|
123
154
|
// UNCONDITIONALLY and gated at execute time on the live session model: pi's
|
|
124
155
|
// picker can switch the model after activation, so a registration-time tier
|
|
125
|
-
// check would both leave the tool live after switching away from
|
|
126
|
-
// hide it forever from a session that switched TO
|
|
156
|
+
// check would both leave the tool live after switching away from Advanced and
|
|
157
|
+
// hide it forever from a session that switched TO Advanced. One state handle
|
|
127
158
|
// per session, shared with /advise so they draw on the same cap.
|
|
128
159
|
const advisorState = makeAdvisorState();
|
|
129
160
|
const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
|
|
@@ -131,12 +162,6 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
131
162
|
// /advise runs the SAME tool, sharing the state handle, so a manual consult
|
|
132
163
|
// draws on the same cap rather than opening a side channel around it.
|
|
133
164
|
registerAdviseCommand(pi, askAdvisorTool);
|
|
134
|
-
// /boost — the sanctioned session-scoped escalation to Peak (spec §7,
|
|
135
|
-
// YAG-380 follow-on). Unlike /advise's per-call consult, this flips the
|
|
136
|
-
// DRIVER's own live model to peak until /boost off or the session ends.
|
|
137
|
-
// The returned handle threads isBoosted() into /cost below, the same way
|
|
138
|
-
// advisorState threads into advisorSubtotal.
|
|
139
|
-
const boostCommand = registerBoostCommand(pi, { env });
|
|
140
165
|
// The differentiated business-grounded tools (loop bricks): review a change
|
|
141
166
|
// for business fit, rank the next work by business priority, and record the
|
|
142
167
|
// engineering rationale back onto the work-item.
|
|
@@ -212,7 +237,98 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
212
237
|
// drafts a decision on "don't ask again". Default auto, so still additive.
|
|
213
238
|
// Mutating MCP tools join write/edit/bash in the gate policy: plan mode
|
|
214
239
|
// holds them, review mode confirms them.
|
|
240
|
+
const modeHolder = createModeHolder();
|
|
241
|
+
const guardianState = makeGuardianState();
|
|
242
|
+
// Disabled by the local env override OR the workspace kill switch
|
|
243
|
+
// (yagni_code.guardian, read from the catalog response at launch). The env
|
|
244
|
+
// var wins for a single developer's debugging; the flag turns it off for
|
|
245
|
+
// every session in the workspace.
|
|
246
|
+
const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
|
|
247
|
+
env.YAGNI_DISABLE_GUARDIAN === "true" ||
|
|
248
|
+
!workspaceGuardianEnabled;
|
|
249
|
+
const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
|
|
250
|
+
const guardianLimits = resolveGuardianLimits(env);
|
|
251
|
+
// YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
|
|
252
|
+
// never the command). The remote guardian-events stream below is the
|
|
253
|
+
// separate, opt-in, per-workspace analytics sink; the two are independent.
|
|
254
|
+
const guardianLogSink = (payload) => {
|
|
255
|
+
try {
|
|
256
|
+
if (process.env.NODE_TEST_CONTEXT)
|
|
257
|
+
return;
|
|
258
|
+
const logPath = join(codeStateHome(null), "logs", "guardian.log");
|
|
259
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
260
|
+
appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
|
|
261
|
+
}
|
|
262
|
+
catch { /* logging must never break the session */ }
|
|
263
|
+
};
|
|
264
|
+
// 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).
|
|
266
|
+
const sessionGrants = evalMode ? [] : loadGrants();
|
|
267
|
+
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
215
268
|
registerPermissionGate(pi, {
|
|
269
|
+
modeHolder,
|
|
270
|
+
guardianState,
|
|
271
|
+
guardianLimits,
|
|
272
|
+
guardianTier,
|
|
273
|
+
guardianDisabled,
|
|
274
|
+
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
275
|
+
onGuardianReview: (ev) => guardianLogSink(ev),
|
|
276
|
+
grants: sessionGrants,
|
|
277
|
+
resolveRepoKey,
|
|
278
|
+
persistGrant: (grant) => {
|
|
279
|
+
if (!evalMode)
|
|
280
|
+
appendGrant(grant);
|
|
281
|
+
},
|
|
282
|
+
// Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
|
|
283
|
+
// "off" → nothing (not even sent); "hash" → sha256 + family prefix +
|
|
284
|
+
// metadata, no command content; "raw" → adds client-REDACTED command and
|
|
285
|
+
// rationale. Fire-and-forget: one attempt, short timeout, failures logged
|
|
286
|
+
// fail-soft to guardian.log — a storage outage never touches the session.
|
|
287
|
+
onGuardianEvent: guardianStorageTier === "off" || evalMode
|
|
288
|
+
? undefined
|
|
289
|
+
: (ev) => {
|
|
290
|
+
void (async () => {
|
|
291
|
+
try {
|
|
292
|
+
const body = {
|
|
293
|
+
sessionId: env.YAGNI_SESSION_ID ?? null,
|
|
294
|
+
commandHash: createHash("sha256").update(ev.command).digest("hex"),
|
|
295
|
+
commandPrefix: storagePrefix(ev.command),
|
|
296
|
+
outcome: ev.outcome,
|
|
297
|
+
mode: ev.mode,
|
|
298
|
+
...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
|
|
299
|
+
...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
|
|
300
|
+
...(ev.tier ? { tier: ev.tier } : {}),
|
|
301
|
+
...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
|
|
302
|
+
};
|
|
303
|
+
if (guardianStorageTier === "raw") {
|
|
304
|
+
body.command = redactCommand(ev.command);
|
|
305
|
+
if (ev.rationale)
|
|
306
|
+
body.rationale = redactCommand(ev.rationale);
|
|
307
|
+
}
|
|
308
|
+
const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: {
|
|
311
|
+
"content-type": "application/json",
|
|
312
|
+
authorization: `Bearer ${getTokenFn() ?? ""}`,
|
|
313
|
+
...attributionHeaders(deps.env),
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify(body),
|
|
316
|
+
}, {
|
|
317
|
+
fetchImpl: authedFetch,
|
|
318
|
+
policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
guardianLogSink({
|
|
326
|
+
event: "guardian_event_post_failed",
|
|
327
|
+
error: err instanceof Error ? err.message : "unknown",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
},
|
|
216
332
|
...(mcpMutatingTools.length > 0
|
|
217
333
|
? {
|
|
218
334
|
policy: {
|
|
@@ -224,6 +340,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
224
340
|
...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
|
|
225
341
|
...mcpMutatingTools,
|
|
226
342
|
],
|
|
343
|
+
alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
|
|
227
344
|
},
|
|
228
345
|
}
|
|
229
346
|
: {}),
|
|
@@ -247,12 +364,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
247
364
|
// turn_end accumulator. Thread the subtotal in explicitly for the local
|
|
248
365
|
// fallback line; the server-authoritative line already counts advisor
|
|
249
366
|
// spend as an ordinary caller row.
|
|
250
|
-
advisorSubtotal: () =>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
isBoosted: () => boostCommand.isBoosted(),
|
|
367
|
+
advisorSubtotal: () => {
|
|
368
|
+
const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
|
|
369
|
+
const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
|
|
370
|
+
return [advisor, guardian].filter(Boolean).join(" ");
|
|
371
|
+
},
|
|
256
372
|
fetchHeadroom: async (signal) => {
|
|
257
373
|
try {
|
|
258
374
|
const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: COST_FETCH_TIMEOUT_MS, jitterRatio: 0 } });
|
|
@@ -405,7 +521,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
405
521
|
pi.on("before_agent_start", (event) => noBranding
|
|
406
522
|
? undefined
|
|
407
523
|
: {
|
|
408
|
-
systemPrompt:
|
|
524
|
+
systemPrompt: brandSystemPrompt(event.systemPrompt, {
|
|
525
|
+
contextBrief,
|
|
526
|
+
identity,
|
|
527
|
+
rulesSection,
|
|
528
|
+
}),
|
|
409
529
|
});
|
|
410
530
|
// Two finalized-message guards share this handler (their conditions are
|
|
411
531
|
// mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
|
|
@@ -425,7 +545,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
425
545
|
// renders. The message text deliberately matches neither pi's overflow nor
|
|
426
546
|
// retryable-error patterns: a deterministic empty response should not burn
|
|
427
547
|
// auto-retries or trigger compaction — the user decides what to do next.
|
|
428
|
-
pi.on("message_end", (event, ctx) => {
|
|
548
|
+
pi.on("message_end", async (event, ctx) => {
|
|
429
549
|
const msg = event.message;
|
|
430
550
|
if (msg.role !== "assistant")
|
|
431
551
|
return;
|
|
@@ -440,6 +560,71 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
440
560
|
// post-replacement message, and dropping the marker would defeat the very
|
|
441
561
|
// recovery this error exists to trigger.
|
|
442
562
|
if (msg.stopReason === "error" && msg.errorMessage) {
|
|
563
|
+
// YAG-500: a 401 from the model proxy means the session token expired
|
|
564
|
+
// (or was revoked). Unlike tool 401s (handled by makeAuthedFetch), the
|
|
565
|
+
// model completion path has no 401-retry seam — pi's retryProviderRequest
|
|
566
|
+
// treats 401 as non-retryable, and after_provider_response never fires
|
|
567
|
+
// (the SDK throws before onResponse is reached). So message_end is the
|
|
568
|
+
// only place to detect it and trigger recovery. The regex matches
|
|
569
|
+
// "yagni login" (the backend's auth-error message) but NOT
|
|
570
|
+
// "request_too_large" (YAG-460's overflow marker).
|
|
571
|
+
const isAuthError = /yagni login/i.test(msg.errorMessage)
|
|
572
|
+
&& !/request_too_large|context_too_large/i.test(msg.errorMessage);
|
|
573
|
+
if (isAuthError) {
|
|
574
|
+
lastAuthRecovery = null;
|
|
575
|
+
let rotated = false;
|
|
576
|
+
try {
|
|
577
|
+
rotated = await tokenProvider.refresh();
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
rotated = false;
|
|
581
|
+
}
|
|
582
|
+
lastAuthRecovery = rotated ? "refreshed" : "failed";
|
|
583
|
+
const explanation = rotated
|
|
584
|
+
? "Your session token expired but was refreshed automatically. Re-send your prompt to continue."
|
|
585
|
+
: "Your session token expired and could not be refreshed. Run `yagni login`, then re-send your prompt. If the issue persists, restart YAGNI Code.";
|
|
586
|
+
if (ctx.hasUI) {
|
|
587
|
+
try {
|
|
588
|
+
ctx.ui.notify(explanation, rotated ? "info" : "error");
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// Surfacing the problem must never break the session itself.
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// YAG-500 Fix E: fire a non-fatal crash report so auth failures are
|
|
595
|
+
// visible in Sentry/backend logs. Reuses the crash endpoint with a
|
|
596
|
+
// distinct context. Fire-and-forget, fail-soft. The crash endpoint is
|
|
597
|
+
// now public (no token required), so the report lands even when the
|
|
598
|
+
// session token is expired and refresh failed — the most critical
|
|
599
|
+
// failure signal is no longer silently dropped.
|
|
600
|
+
void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
|
|
601
|
+
// YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
|
|
602
|
+
if (isDebug(env)) {
|
|
603
|
+
try {
|
|
604
|
+
const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
|
|
605
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
606
|
+
appendFileSync(logPath, JSON.stringify({
|
|
607
|
+
ts: new Date().toISOString(),
|
|
608
|
+
status: 401,
|
|
609
|
+
refresh: rotated ? "succeeded" : "failed",
|
|
610
|
+
}) + "\n", "utf8");
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// A diagnostic must never break the session.
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { message: { ...msg, errorMessage: explanation } };
|
|
617
|
+
}
|
|
618
|
+
// YAG-460: the backend proxy answers an oversized conversation with an
|
|
619
|
+
// OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
|
|
620
|
+
// detection matches that marker and runs full recovery — compact, then
|
|
621
|
+
// auto-retry the failed turn — so this branch must NOT call ctx.compact()
|
|
622
|
+
// (it would race the built-in recovery and lose the retry). Its only job
|
|
623
|
+
// is UX: replace the raw `413: {"error":{...}}` JSON with a readable
|
|
624
|
+
// message and tell the user what is happening. The rewritten text KEEPS
|
|
625
|
+
// the `request_too_large` marker verbatim: pi's _checkCompaction reads the
|
|
626
|
+
// post-replacement message, and dropping the marker would defeat the very
|
|
627
|
+
// recovery this error exists to trigger.
|
|
443
628
|
const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
|
|
444
629
|
if (!isContextTooLarge)
|
|
445
630
|
return;
|
|
@@ -486,7 +671,39 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
486
671
|
ctx.ui?.setStatus?.("brand", BRAND_NAME);
|
|
487
672
|
// setHeader replaces the built-in header in place (verified against pi
|
|
488
673
|
// 0.80.2 setExtensionHeader); the factory returns a simple Text component.
|
|
489
|
-
|
|
674
|
+
// Pass the launcher-forwarded CLI version (YAGNI_CODE_VERSION) and the
|
|
675
|
+
// home-collapsed cwd so the masthead shows them like the reference.
|
|
676
|
+
const mastheadVersion = process.env.YAGNI_CODE_VERSION?.trim() || undefined;
|
|
677
|
+
const mastheadCwd = formatCwd(process.cwd(), process.env.HOME);
|
|
678
|
+
ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme, { version: mastheadVersion, cwd: mastheadCwd })));
|
|
679
|
+
// Replace the built-in footer with the YAGNI status bar: folder +
|
|
680
|
+
// [worktree] + branch on line 1, model + token/cost stats + integer
|
|
681
|
+
// context % on line 2, and extension statuses (brand, todos, mode) on
|
|
682
|
+
// line 3. The factory captures ctx so the footer can read session data
|
|
683
|
+
// (token stats, context usage) that isn't on the footerData provider.
|
|
684
|
+
ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
|
|
685
|
+
// Label the collapsed chain-of-thought line so users know it is reasoning
|
|
686
|
+
// and how to reveal the full trace. Harmless when reasoning is expanded
|
|
687
|
+
// (the label only shows on hidden thinking blocks). Fails closed to pi's
|
|
688
|
+
// default "Thinking..." label.
|
|
689
|
+
try {
|
|
690
|
+
ctx.ui?.setHiddenThinkingLabel?.("Reasoning — Ctrl+T to expand");
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// A label must never disrupt session start.
|
|
694
|
+
}
|
|
695
|
+
// One-time hint naming what changed now that reasoning is collapsed by
|
|
696
|
+
// default (option 2). Gated on the launcher's YAGNI_HIDE_THINKING_SEEDED
|
|
697
|
+
// marker so it fires only on the launch that actually seeded the key — it
|
|
698
|
+
// never re-nags on subsequent runs, even though the label above persists.
|
|
699
|
+
if (env.YAGNI_HIDE_THINKING_SEEDED === "1") {
|
|
700
|
+
try {
|
|
701
|
+
ctx.ui?.notify("Reasoning is collapsed by default — Ctrl+T shows the full trace at any time.", "info");
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// A hint must never disrupt session start.
|
|
705
|
+
}
|
|
706
|
+
}
|
|
490
707
|
}
|
|
491
708
|
// Best-effort, once at session start: if the token is at or near expiry, say
|
|
492
709
|
// so via a single notice so a long session does not silently start 401-ing
|
|
@@ -542,10 +759,18 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
542
759
|
}
|
|
543
760
|
export default async function (pi) {
|
|
544
761
|
await registerYagni(pi);
|
|
762
|
+
// cmux session bridge: report lifecycle state (running/idle), workspace
|
|
763
|
+
// naming, and resume bindings to cmux when running inside it. Complete
|
|
764
|
+
// no-op outside cmux — guarded by CMUX_SURFACE_ID. Registered in the
|
|
765
|
+
// default export (not registerYagni) so tests that call registerYagni
|
|
766
|
+
// directly don't get the bridge's handlers.
|
|
767
|
+
registerCmuxBridge(pi);
|
|
545
768
|
}
|
|
546
769
|
export { makeAskYagniTool } from "./askYagniTool.js";
|
|
770
|
+
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
547
771
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
548
772
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
773
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
549
774
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
550
775
|
export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
551
776
|
export { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
@@ -576,7 +801,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
|
|
|
576
801
|
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
|
|
577
802
|
// P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
|
|
578
803
|
// the session bless-with-remember capture hook.
|
|
579
|
-
export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
804
|
+
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
805
|
+
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
|
|
580
806
|
// W4 judgment loop: the session bless store (tool + path-prefix, session-only).
|
|
581
807
|
export { makeBlessStore, blessPath } from "./bless.js";
|
|
582
808
|
// W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* YAGNI Code registers no tool_call handler today, so the interactive session has
|
|
5
5
|
* no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
|
|
6
6
|
* plus a `/mode` command:
|
|
7
|
-
* - auto (default):
|
|
7
|
+
* - auto (default): ordinary coding tools run directly; external tracker
|
|
8
|
+
* changes still require fresh human confirmation.
|
|
8
9
|
* - plan : blocks write/edit/bash so the agent can explore + propose without
|
|
9
10
|
* touching the tree.
|
|
10
11
|
* - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* adds a session-scoped bless rule AND drafts a decision capture.
|
|
15
16
|
*
|
|
16
17
|
* `decideGate` is PURE; the live wiring holds the mode in a small closure (no
|
|
17
|
-
* module-global state). The default auto mode remains
|
|
18
|
+
* module-global state). The default auto mode remains direct for coding tools, but stricter
|
|
18
19
|
* modes fail closed for side-effect tools if the gate itself errors. Bless rules
|
|
19
20
|
* are session-scoped, path-prefix-bound, never persisted, and never consulted in
|
|
20
21
|
* plan mode (plan blocks outright before isBlessed is reached).
|
|
@@ -26,20 +27,42 @@
|
|
|
26
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
27
28
|
*/
|
|
28
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
|
|
29
31
|
import { type BlessStore } from "./bless.js";
|
|
32
|
+
import { type ExecPolicy } from "./execPolicy.js";
|
|
33
|
+
import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
|
|
30
34
|
export type PermissionMode = "auto" | "plan" | "review";
|
|
35
|
+
/**
|
|
36
|
+
* A shared, mutable holder for the current permission mode. Both
|
|
37
|
+
* `registerPermissionGate` (which writes on /mode) and the footer factory
|
|
38
|
+
* (which reads on every render) hold a reference, so mode changes appear
|
|
39
|
+
* immediately in the footer's line 2.
|
|
40
|
+
*/
|
|
41
|
+
export interface ModeHolder {
|
|
42
|
+
get(): PermissionMode;
|
|
43
|
+
set(m: PermissionMode): void;
|
|
44
|
+
}
|
|
45
|
+
export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
|
|
31
46
|
/** Which tools each tier acts on, plus the optional grounding-bless predicate. */
|
|
32
47
|
export interface PermissionPolicy {
|
|
33
48
|
/** Tools blocked outright in plan mode (write/exec). */
|
|
34
49
|
planBlockTools: string[];
|
|
35
50
|
/** Tools that prompt for confirmation in review mode. */
|
|
36
51
|
reviewConfirmTools: string[];
|
|
52
|
+
/** Consequential external writes that require fresh consent in every mode. */
|
|
53
|
+
alwaysConfirmTools?: string[];
|
|
37
54
|
/**
|
|
38
55
|
* Optional: a recorded decision already blesses this action, so it auto-runs in
|
|
39
56
|
* review mode instead of prompting. The hook for tying the gate to captured
|
|
40
57
|
* judgment; default undefined (nothing pre-blessed).
|
|
41
58
|
*/
|
|
42
59
|
isBlessed?: (toolName: string, params: Record<string, unknown>) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Exec policy for bash command classification. When present, bash commands are
|
|
62
|
+
* classified by the exec policy engine before the tool-granular logic runs.
|
|
63
|
+
* Defaults to DEFAULT_EXEC_POLICY when absent.
|
|
64
|
+
*/
|
|
65
|
+
execPolicy?: ExecPolicy;
|
|
43
66
|
}
|
|
44
67
|
export declare const DEFAULT_PERMISSION_POLICY: PermissionPolicy;
|
|
45
68
|
/** A pure gate verdict: block outright, ask to confirm, or allow. */
|
|
@@ -48,13 +71,55 @@ export interface GateDecision {
|
|
|
48
71
|
reason?: string;
|
|
49
72
|
/** review mode only: the caller should ctx.ui.confirm before allowing. */
|
|
50
73
|
confirm?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Exec policy classification result for bash commands. When present, the
|
|
76
|
+
* tool_call handler should run the Guardian for "prompt" before falling
|
|
77
|
+
* through to the user confirm. Absent for non-bash tools or when no
|
|
78
|
+
* command string is available.
|
|
79
|
+
*/
|
|
80
|
+
classify?: "allow" | "prompt" | "forbidden";
|
|
81
|
+
/**
|
|
82
|
+
* The exec policy's justification for a "prompt" classification — why the
|
|
83
|
+
* command was routed to the Guardian. Threaded into the consult prompt and
|
|
84
|
+
* the storage event (it is the layer-tuning signal, YAG-510).
|
|
85
|
+
*/
|
|
86
|
+
classifyJustification?: string;
|
|
51
87
|
}
|
|
52
88
|
/**
|
|
53
|
-
* Pure permission decision for one tool call under a mode + policy.
|
|
54
|
-
*
|
|
89
|
+
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
90
|
+
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
55
91
|
* unless a recorded decision blesses them.
|
|
56
92
|
*/
|
|
57
93
|
export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
|
|
94
|
+
/**
|
|
95
|
+
* Terminal outcome of one prompt-band decision (YAG-510). One storage event
|
|
96
|
+
* is emitted per terminal outcome; `consulted` says whether a Guardian LLM
|
|
97
|
+
* call actually happened (grants/cache hits skip it).
|
|
98
|
+
*/
|
|
99
|
+
export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | GuardianError;
|
|
100
|
+
/**
|
|
101
|
+
* Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
|
|
102
|
+
* command — the wiring layer (index.ts) hashes/redacts per the workspace's
|
|
103
|
+
* storage tier before anything leaves the machine. Distinct from the
|
|
104
|
+
* sanitized GuardianDiagnosticEvent (local debug log), which never carries
|
|
105
|
+
* the command.
|
|
106
|
+
*/
|
|
107
|
+
export interface GuardianGateEvent {
|
|
108
|
+
command: string;
|
|
109
|
+
outcome: GuardianGateOutcome;
|
|
110
|
+
/** Why the exec policy routed this command to the Guardian. */
|
|
111
|
+
execJustification?: string;
|
|
112
|
+
riskLevel?: GuardianRiskLevel;
|
|
113
|
+
rationale?: string;
|
|
114
|
+
mode: PermissionMode;
|
|
115
|
+
tier?: string;
|
|
116
|
+
durationMs?: number;
|
|
117
|
+
/** Whether a Guardian LLM consult actually ran for this decision. */
|
|
118
|
+
consulted: boolean;
|
|
119
|
+
/** Present when the terminal outcome followed a Guardian error (e.g. an
|
|
120
|
+
* error-fallback ask that the user then approved). */
|
|
121
|
+
guardianError?: GuardianError;
|
|
122
|
+
}
|
|
58
123
|
/** What was blessed with "don't ask again", handed to the capture hook. */
|
|
59
124
|
export interface BlessRememberInfo {
|
|
60
125
|
tool: string;
|
|
@@ -73,16 +138,64 @@ export interface RegisterPermissionDeps {
|
|
|
73
138
|
* never blocks the approved tool call.
|
|
74
139
|
*/
|
|
75
140
|
onBlessRemember?: (ctx: ExtensionContext, info: BlessRememberInfo) => void | Promise<void>;
|
|
141
|
+
/** Shared holder so the footer can read the live mode on every render. */
|
|
142
|
+
modeHolder?: ModeHolder;
|
|
143
|
+
/**
|
|
144
|
+
* Guardian state handle for the session. When present, prompt-band bash
|
|
145
|
+
* commands are auto-reviewed by the Guardian LLM instead of interrupting
|
|
146
|
+
* the user. When absent, prompt-band commands fall through to the existing
|
|
147
|
+
* tool-granular behavior (review: user prompt, auto: allow).
|
|
148
|
+
*/
|
|
149
|
+
guardianState?: import("./guardian.js").GuardianStateHandle;
|
|
150
|
+
/** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
|
|
151
|
+
guardianLimits?: import("./guardian.js").GuardianLimits;
|
|
152
|
+
/** Override the Guardian model tier (default: efficient). */
|
|
153
|
+
guardianTier?: string;
|
|
154
|
+
/** Injectable Guardian review function (tests pass a stub). */
|
|
155
|
+
guardianReview?: (command: string, deps: import("./guardian.js").ReviewCommandDeps) => Promise<import("./guardian.js").ReviewResult>;
|
|
156
|
+
/**
|
|
157
|
+
* Called (fire-and-forget) after each Guardian review with a sanitized
|
|
158
|
+
* diagnostic event. Fail-soft; never blocks.
|
|
159
|
+
*/
|
|
160
|
+
onGuardianReview?: (event: import("./guardian.js").GuardianDiagnosticEvent) => void;
|
|
161
|
+
/** Disable the Guardian entirely (env var YAGNI_DISABLE_GUARDIAN). */
|
|
162
|
+
guardianDisabled?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Persisted "don't ask again" grants for this session's repo (YAG-510).
|
|
165
|
+
* Consulted ONLY after the exec policy classified a command as "prompt" —
|
|
166
|
+
* a grant can never override the forbidden band. Suppressed in review mode.
|
|
167
|
+
*/
|
|
168
|
+
grants?: readonly ApprovedPrefixGrant[];
|
|
169
|
+
/** Resolve the grant scope key for a cwd (git remote origin URL fallback
|
|
170
|
+
* realpath). Lazily invoked on the first prompt-band bash call. */
|
|
171
|
+
resolveRepoKey?: (cwd: string) => string;
|
|
172
|
+
/** Persist a new grant (fire-and-forget; the in-memory list is updated
|
|
173
|
+
* either way). index.ts wires approvedPrefixes.appendGrant. */
|
|
174
|
+
persistGrant?: (grant: ApprovedPrefixGrant) => void;
|
|
175
|
+
/**
|
|
176
|
+
* Called (fire-and-forget) at every terminal prompt-band outcome with the
|
|
177
|
+
* rich storage event (raw command — the wiring layer redacts/hashes).
|
|
178
|
+
* Fail-soft; never blocks.
|
|
179
|
+
*/
|
|
180
|
+
onGuardianEvent?: (event: GuardianGateEvent) => void;
|
|
76
181
|
}
|
|
77
|
-
/** The customType tag on injected
|
|
78
|
-
export declare const
|
|
182
|
+
/** The customType tag on injected mode-context messages (filterable later). */
|
|
183
|
+
export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
184
|
+
/** Legacy alias — the original plan-mode tag, kept for backward compat. */
|
|
185
|
+
export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
|
|
79
186
|
export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
|
|
187
|
+
/** Build the mode-awareness context message for the current permission mode. */
|
|
188
|
+
export declare function buildModeContextMessage(mode: PermissionMode): string;
|
|
80
189
|
/**
|
|
81
|
-
* Drop previously injected
|
|
82
|
-
* model
|
|
83
|
-
*
|
|
190
|
+
* Drop previously injected mode-context messages from a DIFFERENT mode so the
|
|
191
|
+
* model does not keep believing it is in a prior mode. Messages matching the
|
|
192
|
+
* current mode are kept (the fresh injection from before_agent_start should
|
|
193
|
+
* survive). Pure; returns the SAME array when nothing needs filtering so callers
|
|
194
|
+
* can cheaply detect a no-op.
|
|
84
195
|
*/
|
|
85
|
-
export declare function
|
|
196
|
+
export declare function filterStaleModeContext<T>(messages: T[], currentMode?: PermissionMode): T[];
|
|
197
|
+
/** Legacy alias — the original plan-mode filter name. */
|
|
198
|
+
export declare const filterStalePlanContext: typeof filterStaleModeContext;
|
|
86
199
|
/**
|
|
87
200
|
* Wire the tool_call gate + the /mode command onto a shared mode holder. Default
|
|
88
201
|
* auto, so absent any /mode this is a no-op over today's behavior.
|