@yagni-app/code-staging 0.3.0-staging.1085.1 → 0.3.0-staging.1090.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/extension/approvedPrefixes.d.ts +11 -0
- package/dist/extension/approvedPrefixes.js +30 -0
- package/dist/extension/askAdvisorTool.d.ts +17 -2
- package/dist/extension/askAdvisorTool.js +112 -14
- package/dist/extension/guardian.d.ts +14 -4
- package/dist/extension/guardian.js +34 -10
- package/dist/extension/index.js +3 -1
- package/dist/extension/permission.js +10 -4
- package/dist/extension/pipeline/goCommand.js +6 -4
- package/dist/extension/pipeline/runRegistry.d.ts +9 -1
- package/dist/extension/pipeline/runRegistry.js +22 -1
- package/package.json +2 -2
|
@@ -73,6 +73,17 @@ export declare function matchesGrant(command: string, grants: readonly ApprovedP
|
|
|
73
73
|
export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
|
|
74
74
|
/** Human label for the remember option: "git push …". */
|
|
75
75
|
export declare function describePrefix(pattern: string[]): string;
|
|
76
|
+
/**
|
|
77
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
78
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
79
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
80
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
81
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
82
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
83
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
84
|
+
* gate's own ask flow.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isDerivablePattern(pattern: readonly string[]): boolean;
|
|
76
87
|
export declare function rulesFilePath(homeOverride?: string | null): string;
|
|
77
88
|
/**
|
|
78
89
|
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
@@ -179,6 +179,35 @@ export function validateGrant(command, policy, repoKey) {
|
|
|
179
179
|
export function describePrefix(pattern) {
|
|
180
180
|
return `${pattern.join(" ")} …`;
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
184
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
185
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
186
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
187
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
188
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
189
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
190
|
+
* gate's own ask flow.
|
|
191
|
+
*/
|
|
192
|
+
export function isDerivablePattern(pattern) {
|
|
193
|
+
if (pattern.length < 1 || pattern.length > 2)
|
|
194
|
+
return false;
|
|
195
|
+
const first = pattern[0];
|
|
196
|
+
if (first.includes("/") || first.startsWith("\\"))
|
|
197
|
+
return false;
|
|
198
|
+
if (BANNED_PREFIXES.has(first))
|
|
199
|
+
return false;
|
|
200
|
+
if (pattern.length === 2) {
|
|
201
|
+
const second = pattern[1];
|
|
202
|
+
if (!MULTI_SUBCOMMAND_TOOLS.has(first))
|
|
203
|
+
return false;
|
|
204
|
+
if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
205
|
+
return false;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// Derivation never emits a bare multi-subcommand tool ("git" alone).
|
|
209
|
+
return !MULTI_SUBCOMMAND_TOOLS.has(first);
|
|
210
|
+
}
|
|
182
211
|
// --- I/O half ---
|
|
183
212
|
export function rulesFilePath(homeOverride = null) {
|
|
184
213
|
return join(codeStateHome(homeOverride), "rules.json");
|
|
@@ -220,6 +249,7 @@ export function loadGrants(homeOverride = null) {
|
|
|
220
249
|
return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
|
|
221
250
|
g.pattern.length > 0 &&
|
|
222
251
|
g.pattern.every((t) => typeof t === "string") &&
|
|
252
|
+
isDerivablePattern(g.pattern) &&
|
|
223
253
|
typeof g.repoKey === "string" &&
|
|
224
254
|
typeof g.addedAt === "string" &&
|
|
225
255
|
typeof g.cwd === "string");
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* this module is the I/O.
|
|
27
27
|
*/
|
|
28
28
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
29
30
|
import { Type } from "typebox";
|
|
30
31
|
import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
|
|
31
32
|
import { runStage as defaultRunStage } from "./pipeline/runner.js";
|
|
32
33
|
import type { PipelineStage } from "./pipeline/types.js";
|
|
34
|
+
import { type RenderTheme, type SubagentTaskProgress } from "./subagentRender.js";
|
|
33
35
|
/**
|
|
34
36
|
* Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
|
|
35
37
|
* reason documented there: enough tools to navigate instead of flailing on a
|
|
@@ -66,10 +68,18 @@ export declare function buildConsultBrief(params: {
|
|
|
66
68
|
* persona, and that is the advisor's own.
|
|
67
69
|
*/
|
|
68
70
|
export declare function advisorStage(): PipelineStage;
|
|
69
|
-
|
|
71
|
+
/** The ask_advisor result details: consult accounting plus the live progress
|
|
72
|
+
* record the subagent renderers paint (one task, the consult itself). */
|
|
73
|
+
export interface AdvisorToolDetails {
|
|
70
74
|
consults: number;
|
|
71
75
|
cost: number;
|
|
72
|
-
|
|
76
|
+
tasks?: SubagentTaskProgress[];
|
|
77
|
+
}
|
|
78
|
+
/** Title painted the moment the ask_advisor call streams in. */
|
|
79
|
+
export declare function renderAdvisorCall(args: {
|
|
80
|
+
question?: string;
|
|
81
|
+
} | undefined, theme: RenderTheme, _context: unknown): Component;
|
|
82
|
+
export declare function makeAskAdvisorTool(opts: MakeAskAdvisorToolOptions): ToolDefinition<typeof parameters, AdvisorToolDetails>;
|
|
73
83
|
/**
|
|
74
84
|
* `/advise <question>` — the manual escalation lever.
|
|
75
85
|
*
|
|
@@ -77,6 +87,11 @@ export declare function makeAskAdvisorTool(opts: MakeAskAdvisorToolOptions): Too
|
|
|
77
87
|
* consult draws on the same cap rather than opening a side channel around it.
|
|
78
88
|
* The advice is sent into the conversation (like /go-compare's report) so the
|
|
79
89
|
* driver sees it in context and can act on it.
|
|
90
|
+
*
|
|
91
|
+
* A command has no tool row for pi to render, so the live view is an
|
|
92
|
+
* aboveEditor widget painted from the tool's partial updates: the same
|
|
93
|
+
* two-line `agent — task` / `↳ current tool` status the subagent tool shows,
|
|
94
|
+
* driven by a spinner ticker so it visibly moves between child events.
|
|
80
95
|
*/
|
|
81
96
|
export declare function registerAdviseCommand(pi: ExtensionAPI, tool: ReturnType<typeof makeAskAdvisorTool>): void;
|
|
82
97
|
export {};
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
* The gate, the cap and the spend ceiling live in `advisor.ts` and are pure;
|
|
26
26
|
* this module is the I/O.
|
|
27
27
|
*/
|
|
28
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
28
29
|
import { Type } from "typebox";
|
|
29
30
|
import { ADVISOR_MODEL_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatConsultCost, } from "./advisor.js";
|
|
31
|
+
import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
|
|
30
32
|
import { runStage as defaultRunStage } from "./pipeline/runner.js";
|
|
33
|
+
import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, receiptLine, renderSubagentResult, runningLines, } from "./subagentRender.js";
|
|
31
34
|
/**
|
|
32
35
|
* Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
|
|
33
36
|
* reason documented there: enough tools to navigate instead of flailing on a
|
|
@@ -71,6 +74,24 @@ export function advisorStage() {
|
|
|
71
74
|
taskTemplate: "{ticket}",
|
|
72
75
|
};
|
|
73
76
|
}
|
|
77
|
+
/** Spinner cadence for the advisor's live views; matches subagentRender. */
|
|
78
|
+
const SPINNER_TICK_MS = 120;
|
|
79
|
+
/** Question preview width on the call title line. */
|
|
80
|
+
const QUESTION_PREVIEW_MAX = 128;
|
|
81
|
+
function clip(text, max) {
|
|
82
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
83
|
+
if (collapsed.length <= max)
|
|
84
|
+
return collapsed;
|
|
85
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
86
|
+
}
|
|
87
|
+
/** Title painted the moment the ask_advisor call streams in. */
|
|
88
|
+
export function renderAdvisorCall(args, theme, _context) {
|
|
89
|
+
const title = theme.fg("toolTitle", theme.bold("advisor"));
|
|
90
|
+
let text = `${title} ${theme.fg("accent", "peak-tier consult")}`;
|
|
91
|
+
if (args?.question)
|
|
92
|
+
text += `\n ${theme.fg("dim", clip(args.question, QUESTION_PREVIEW_MAX))}`;
|
|
93
|
+
return new Text(text, 0, 0);
|
|
94
|
+
}
|
|
74
95
|
export function makeAskAdvisorTool(opts) {
|
|
75
96
|
const limits = opts.limits ?? DEFAULT_ADVISOR_LIMITS;
|
|
76
97
|
const runStage = opts.runStage ?? defaultRunStage;
|
|
@@ -95,6 +116,8 @@ export function makeAskAdvisorTool(opts) {
|
|
|
95
116
|
"The advice comes back as plain text: act on it, and call record_decision when it settles a product-intent call so the next agent inherits it.",
|
|
96
117
|
],
|
|
97
118
|
parameters,
|
|
119
|
+
renderCall: renderAdvisorCall,
|
|
120
|
+
renderResult: renderSubagentResult,
|
|
98
121
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
99
122
|
// Read the LIVE session model: pi's picker can change it after this tool
|
|
100
123
|
// was registered, in both directions.
|
|
@@ -109,19 +132,51 @@ export function makeAskAdvisorTool(opts) {
|
|
|
109
132
|
details: { consults: opts.state.read().consults, cost: 0 },
|
|
110
133
|
};
|
|
111
134
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
// Live progress, exactly the subagent tool's shape: the folded record
|
|
136
|
+
// rides every partial update's `details.tasks` (painted by
|
|
137
|
+
// renderSubagentResult), the plain-text summary rides `content` for
|
|
138
|
+
// headless consumers, and the harness "Working…" line mirrors it.
|
|
139
|
+
const progress = newTaskProgress("advisor", params.question, Date.now());
|
|
140
|
+
const ui = ctx?.hasUI ? ctx.ui : undefined;
|
|
141
|
+
let lastWorking;
|
|
142
|
+
const emit = () => {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
onUpdate?.({
|
|
145
|
+
content: [{ type: "text", text: progressSummaryText([progress], now) }],
|
|
146
|
+
details: {
|
|
147
|
+
consults: opts.state.read().consults,
|
|
148
|
+
cost: progress.usage.cost,
|
|
149
|
+
tasks: [{ ...progress, actions: [...progress.actions], usage: { ...progress.usage } }],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const working = formatWorkingMessage([progress], now);
|
|
153
|
+
if (ui && working !== lastWorking) {
|
|
154
|
+
lastWorking = working;
|
|
155
|
+
ui.setWorkingMessage?.(working);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
emit();
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
|
|
162
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
163
|
+
...(signal ? { signal } : {}),
|
|
164
|
+
// YAG-471: attribute the consult's completions to the advisor, not
|
|
165
|
+
// the "plan" stage id advisorStage() borrows (see its docblock).
|
|
166
|
+
callerLabel: "advisor",
|
|
167
|
+
onEvent: (ev) => {
|
|
168
|
+
if (applyChildEvent(progress, ev))
|
|
169
|
+
emit();
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
// Restore the default "Working…" text whether we resolved or threw.
|
|
175
|
+
ui?.setWorkingMessage?.();
|
|
176
|
+
}
|
|
123
177
|
const cost = result.usage?.cost ?? 0;
|
|
124
178
|
const state = opts.state.record(cost);
|
|
179
|
+
finalizeTask(progress, result, Date.now());
|
|
125
180
|
if (result.exitCode !== 0 && !result.finalOutput.trim()) {
|
|
126
181
|
// Fail honestly rather than returning an empty recommendation. The
|
|
127
182
|
// consult still counts: it spawned, and it may well have spent.
|
|
@@ -134,7 +189,7 @@ export function makeAskAdvisorTool(opts) {
|
|
|
134
189
|
`Decide this one yourself.`,
|
|
135
190
|
},
|
|
136
191
|
],
|
|
137
|
-
details: { consults: state.consults, cost },
|
|
192
|
+
details: { consults: state.consults, cost, tasks: [progress] },
|
|
138
193
|
};
|
|
139
194
|
}
|
|
140
195
|
return {
|
|
@@ -144,11 +199,13 @@ export function makeAskAdvisorTool(opts) {
|
|
|
144
199
|
text: `${result.finalOutput.trim()}\n\n${formatConsultCost({ cost, consults: state.consults, limits })}`,
|
|
145
200
|
},
|
|
146
201
|
],
|
|
147
|
-
details: { consults: state.consults, cost },
|
|
202
|
+
details: { consults: state.consults, cost, tasks: [progress] },
|
|
148
203
|
};
|
|
149
204
|
},
|
|
150
205
|
};
|
|
151
206
|
}
|
|
207
|
+
/** The widget key the /advise live panel paints under. */
|
|
208
|
+
const ADVISE_WIDGET_KEY = "yagni-advise";
|
|
152
209
|
/**
|
|
153
210
|
* `/advise <question>` — the manual escalation lever.
|
|
154
211
|
*
|
|
@@ -156,6 +213,11 @@ export function makeAskAdvisorTool(opts) {
|
|
|
156
213
|
* consult draws on the same cap rather than opening a side channel around it.
|
|
157
214
|
* The advice is sent into the conversation (like /go-compare's report) so the
|
|
158
215
|
* driver sees it in context and can act on it.
|
|
216
|
+
*
|
|
217
|
+
* A command has no tool row for pi to render, so the live view is an
|
|
218
|
+
* aboveEditor widget painted from the tool's partial updates: the same
|
|
219
|
+
* two-line `agent — task` / `↳ current tool` status the subagent tool shows,
|
|
220
|
+
* driven by a spinner ticker so it visibly moves between child events.
|
|
159
221
|
*/
|
|
160
222
|
export function registerAdviseCommand(pi, tool) {
|
|
161
223
|
pi.registerCommand("advise", {
|
|
@@ -175,8 +237,41 @@ export function registerAdviseCommand(pi, tool) {
|
|
|
175
237
|
return;
|
|
176
238
|
}
|
|
177
239
|
notify("Consulting the peak-tier advisor. This reads the repo, so it takes a moment.", "info");
|
|
240
|
+
// Live panel state: the newest folded progress record from the tool's
|
|
241
|
+
// partial updates, repainted on a spinner tick until the consult ends.
|
|
242
|
+
let progress;
|
|
243
|
+
let paintTimer;
|
|
244
|
+
const theme = ctx.hasUI ? ctx.ui.theme : undefined;
|
|
245
|
+
const paint = () => {
|
|
246
|
+
if (!ctx.hasUI || !theme || !progress)
|
|
247
|
+
return;
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const lines = progress.status === "running"
|
|
250
|
+
? runningLines(progress, theme, now, SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length])
|
|
251
|
+
: [receiptLine(progress, theme)];
|
|
252
|
+
ctx.ui.setWidget?.(ADVISE_WIDGET_KEY, lines, { placement: "aboveEditor" });
|
|
253
|
+
};
|
|
254
|
+
const onUpdate = (update) => {
|
|
255
|
+
const task = update.details?.tasks?.[0];
|
|
256
|
+
if (!task)
|
|
257
|
+
return;
|
|
258
|
+
progress = task;
|
|
259
|
+
if (ctx.hasUI && theme && !paintTimer) {
|
|
260
|
+
paintTimer = setInterval(paint, SPINNER_TICK_MS);
|
|
261
|
+
paintTimer.unref?.();
|
|
262
|
+
}
|
|
263
|
+
paint();
|
|
264
|
+
};
|
|
265
|
+
const clearPanel = () => {
|
|
266
|
+
if (paintTimer) {
|
|
267
|
+
clearInterval(paintTimer);
|
|
268
|
+
paintTimer = undefined;
|
|
269
|
+
}
|
|
270
|
+
if (ctx.hasUI)
|
|
271
|
+
ctx.ui.setWidget?.(ADVISE_WIDGET_KEY, undefined);
|
|
272
|
+
};
|
|
178
273
|
try {
|
|
179
|
-
const out = await tool.execute("advise", { question }, ctx.signal,
|
|
274
|
+
const out = await tool.execute("advise", { question }, ctx.signal, onUpdate, ctx);
|
|
180
275
|
const text = out.content
|
|
181
276
|
.map((c) => c.text ?? "")
|
|
182
277
|
.join("\n")
|
|
@@ -188,6 +283,9 @@ export function registerAdviseCommand(pi, tool) {
|
|
|
188
283
|
notify(`/advise failed: ${message}`, "error");
|
|
189
284
|
await pi.sendUserMessage(`/advise failed: ${message}`);
|
|
190
285
|
}
|
|
286
|
+
finally {
|
|
287
|
+
clearPanel();
|
|
288
|
+
}
|
|
191
289
|
},
|
|
192
290
|
});
|
|
193
291
|
}
|
|
@@ -37,7 +37,7 @@ export interface GuardianVerdict {
|
|
|
37
37
|
rationale: string;
|
|
38
38
|
}
|
|
39
39
|
export interface GuardianLimits {
|
|
40
|
-
/**
|
|
40
|
+
/** Cap on Guardian reviews within the sliding window ({@link GUARDIAN_REVIEW_WINDOW_MS}). */
|
|
41
41
|
maxReviews: number;
|
|
42
42
|
/** Consecutive denials per turn before the circuit breaker trips. */
|
|
43
43
|
maxConsecutiveDenials: number;
|
|
@@ -47,15 +47,25 @@ export interface GuardianLimits {
|
|
|
47
47
|
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
48
48
|
/**
|
|
49
49
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
50
|
-
* overrides the
|
|
51
|
-
* the default (a bad value must never zero out the cap and lock the
|
|
50
|
+
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
51
|
+
* back to the default (a bad value must never zero out the cap and lock the
|
|
52
|
+
* session).
|
|
52
53
|
*/
|
|
53
54
|
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
54
55
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
55
56
|
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
56
57
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
57
58
|
export declare const GUARDIAN_TOOLS: string[];
|
|
59
|
+
/**
|
|
60
|
+
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
61
|
+
* rather than for the session's lifetime: a 24/7 session (a fleet operator's
|
|
62
|
+
* always-on terminal) must regain review capacity as old consults age out,
|
|
63
|
+
* not hard-block forever after the first N. The cap is a cost/runaway bound,
|
|
64
|
+
* not a safety bound — safety is the verdicts themselves.
|
|
65
|
+
*/
|
|
66
|
+
export declare const GUARDIAN_REVIEW_WINDOW_MS: number;
|
|
58
67
|
export interface GuardianState {
|
|
68
|
+
/** Guardian consults within the last {@link GUARDIAN_REVIEW_WINDOW_MS}. */
|
|
59
69
|
reviews: number;
|
|
60
70
|
consecutiveDenials: number;
|
|
61
71
|
}
|
|
@@ -64,7 +74,7 @@ export interface GuardianStateHandle {
|
|
|
64
74
|
recordReview(outcome: GuardianOutcome): GuardianState;
|
|
65
75
|
resetTurn(): void;
|
|
66
76
|
}
|
|
67
|
-
export declare function makeGuardianState(): GuardianStateHandle;
|
|
77
|
+
export declare function makeGuardianState(now?: () => number): GuardianStateHandle;
|
|
68
78
|
export interface CircuitBreakerResult {
|
|
69
79
|
tripped: boolean;
|
|
70
80
|
reason?: string;
|
|
@@ -35,8 +35,9 @@ export const DEFAULT_GUARDIAN_LIMITS = {
|
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
37
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
38
|
-
* overrides the
|
|
39
|
-
* the default (a bad value must never zero out the cap and lock the
|
|
38
|
+
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
39
|
+
* back to the default (a bad value must never zero out the cap and lock the
|
|
40
|
+
* session).
|
|
40
41
|
*/
|
|
41
42
|
export function resolveGuardianLimits(env = process.env) {
|
|
42
43
|
const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
|
|
@@ -48,25 +49,48 @@ export function resolveGuardianLimits(env = process.env) {
|
|
|
48
49
|
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
49
50
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
50
51
|
export const GUARDIAN_TOOLS = ["read"];
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// --- State ---
|
|
53
|
+
/**
|
|
54
|
+
* The review-cap window. `reviews` counts consults inside a SLIDING window
|
|
55
|
+
* rather than for the session's lifetime: a 24/7 session (a fleet operator's
|
|
56
|
+
* always-on terminal) must regain review capacity as old consults age out,
|
|
57
|
+
* not hard-block forever after the first N. The cap is a cost/runaway bound,
|
|
58
|
+
* not a safety bound — safety is the verdicts themselves.
|
|
59
|
+
*/
|
|
60
|
+
export const GUARDIAN_REVIEW_WINDOW_MS = 60 * 60_000;
|
|
61
|
+
export function makeGuardianState(now = Date.now) {
|
|
62
|
+
const reviewTimes = [];
|
|
63
|
+
let consecutiveDenials = 0;
|
|
64
|
+
const prune = () => {
|
|
65
|
+
const cutoff = now() - GUARDIAN_REVIEW_WINDOW_MS;
|
|
66
|
+
while (reviewTimes.length > 0 && reviewTimes[0] <= cutoff)
|
|
67
|
+
reviewTimes.shift();
|
|
68
|
+
};
|
|
69
|
+
const snapshot = () => ({
|
|
70
|
+
reviews: reviewTimes.length,
|
|
71
|
+
consecutiveDenials,
|
|
72
|
+
});
|
|
53
73
|
return {
|
|
54
|
-
read: () =>
|
|
74
|
+
read: () => {
|
|
75
|
+
prune();
|
|
76
|
+
return snapshot();
|
|
77
|
+
},
|
|
55
78
|
recordReview(outcome) {
|
|
56
|
-
|
|
79
|
+
prune();
|
|
80
|
+
reviewTimes.push(now());
|
|
57
81
|
if (outcome === "deny") {
|
|
58
|
-
|
|
82
|
+
consecutiveDenials += 1;
|
|
59
83
|
}
|
|
60
84
|
else if (outcome === "allow") {
|
|
61
|
-
|
|
85
|
+
consecutiveDenials = 0;
|
|
62
86
|
}
|
|
63
87
|
// "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
|
|
64
88
|
// an exoneration. If it reset the streak, deny/ask/deny/ask would never
|
|
65
89
|
// trip the breaker (round-2 review blocker).
|
|
66
|
-
return
|
|
90
|
+
return snapshot();
|
|
67
91
|
},
|
|
68
92
|
resetTurn() {
|
|
69
|
-
|
|
93
|
+
consecutiveDenials = 0;
|
|
70
94
|
},
|
|
71
95
|
};
|
|
72
96
|
}
|
package/dist/extension/index.js
CHANGED
|
@@ -268,7 +268,9 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
268
268
|
catch { /* logging must never break the session */ }
|
|
269
269
|
};
|
|
270
270
|
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
|
|
271
|
-
// at startup (grants added by other concurrent sessions appear next launch
|
|
271
|
+
// at startup (grants added by other concurrent sessions appear next launch —
|
|
272
|
+
// the startup load is the trust boundary; live reload was reviewed and
|
|
273
|
+
// rejected as a same-session self-authorization path, PR #1698).
|
|
272
274
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
273
275
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
274
276
|
registerPermissionGate(pi, {
|
|
@@ -279,6 +279,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
279
279
|
const guardianTier = deps.guardianTier;
|
|
280
280
|
// --- YAG-510 gate state ---
|
|
281
281
|
// Grants: in-memory list seeded from deps, appended on "don't ask again".
|
|
282
|
+
// Deliberately NOT live-reloaded from disk: auto mode can write files, so a
|
|
283
|
+
// mid-session re-read of rules.json would let the agent (or a prompt
|
|
284
|
+
// injection) author its own grants and self-authorize within the same
|
|
285
|
+
// session. New grants from concurrent sessions apply at next launch — the
|
|
286
|
+
// startup load is the trust boundary (PR #1698 review).
|
|
282
287
|
const grants = [...(deps.grants ?? [])];
|
|
283
288
|
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
284
289
|
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
@@ -410,12 +415,13 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
410
415
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
411
416
|
const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
|
|
412
417
|
if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
|
|
413
|
-
//
|
|
414
|
-
//
|
|
418
|
+
// Sliding-window consult cap (capacity recovers as old reviews age
|
|
419
|
+
// out — a long-lived session is never bricked). Review mode falls
|
|
420
|
+
// through to its ordinary confirm (no LLM cost); auto blocks.
|
|
415
421
|
if (modeAtEntry === "auto") {
|
|
416
422
|
if (ctx?.hasUI)
|
|
417
|
-
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews}
|
|
418
|
-
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews}
|
|
423
|
+
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
|
|
424
|
+
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
425
|
}
|
|
420
426
|
// fall through to decision.confirm below
|
|
421
427
|
}
|
|
@@ -77,7 +77,7 @@ import { registerGoStatusCommands } from "./goStatusCommands.js";
|
|
|
77
77
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
78
78
|
import { composeAbortSignal } from "./resilience.js";
|
|
79
79
|
import { planResume } from "./resume.js";
|
|
80
|
-
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows,
|
|
80
|
+
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
81
81
|
import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
|
|
82
82
|
import { recordSessionRun } from "../sessionRuns.js";
|
|
83
83
|
import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
|
|
@@ -460,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
462
|
// In-flight guards: the same ticket never runs twice at once in this
|
|
463
|
-
// process, and at most
|
|
463
|
+
// process, and at most resolveMaxConcurrentRuns() runs are in flight
|
|
464
|
+
// (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
|
|
464
465
|
if (findActiveRunByTicket(ticket)) {
|
|
465
466
|
notify(`/go ${ticket} is already running - see /go-status.`, "warning");
|
|
466
467
|
return;
|
|
467
468
|
}
|
|
468
|
-
|
|
469
|
-
|
|
469
|
+
const maxConcurrentRuns = resolveMaxConcurrentRuns();
|
|
470
|
+
if (activeRunCount() >= maxConcurrentRuns) {
|
|
471
|
+
notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
|
|
470
472
|
return;
|
|
471
473
|
}
|
|
472
474
|
// --- Run tree resolution: worktree by default; --here = legacy in-place.
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* candidate).
|
|
23
23
|
*/
|
|
24
24
|
import type { CheckpointRecord, StopReason } from "./types.js";
|
|
25
|
-
/**
|
|
25
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
26
26
|
export declare const MAX_CONCURRENT_RUNS = 3;
|
|
27
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
28
|
+
export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
31
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
32
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
|
|
27
35
|
/**
|
|
28
36
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
29
37
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -24,8 +24,22 @@
|
|
|
24
24
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { codeStateHome } from "../stateHome.js";
|
|
27
|
-
/**
|
|
27
|
+
/** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
28
28
|
export const MAX_CONCURRENT_RUNS = 3;
|
|
29
|
+
/** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
|
|
30
|
+
export const MAX_CONCURRENT_RUNS_CEILING = 32;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
|
|
33
|
+
* raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
|
|
34
|
+
* falls back to the default, and anything above the ceiling clamps to it.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveMaxConcurrentRuns(env = process.env) {
|
|
37
|
+
const raw = env.YAGNI_MAX_CONCURRENT_RUNS?.trim();
|
|
38
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
40
|
+
return MAX_CONCURRENT_RUNS;
|
|
41
|
+
return Math.min(parsed, MAX_CONCURRENT_RUNS_CEILING);
|
|
42
|
+
}
|
|
29
43
|
/**
|
|
30
44
|
* A non-terminal row whose journal has been quiet this long is treated as
|
|
31
45
|
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
@@ -106,6 +120,13 @@ const active = new Map();
|
|
|
106
120
|
export function _resetRunRegistryForTest() {
|
|
107
121
|
active.clear();
|
|
108
122
|
}
|
|
123
|
+
// NOTE on growth: the mirror is append-only and grows without bound on a
|
|
124
|
+
// long-lived install. In-place compaction was reviewed and REMOVED (PR #1698):
|
|
125
|
+
// a fold+rewrite without cross-process exclusion can permanently erase another
|
|
126
|
+
// process's terminal settle (nothing ever re-appends a final row), which would
|
|
127
|
+
// resurrect a finished run as "interrupted" and invite duplicate worktree
|
|
128
|
+
// adoption. Compaction needs an inter-process lock + unique temp files —
|
|
129
|
+
// tracked separately; until then, growth is the safe failure mode.
|
|
109
130
|
/** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
|
|
110
131
|
function appendRow(row) {
|
|
111
132
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.1090.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
39
|
"typebox": "^1.3.11"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "79e1523affbd694ad96a236da6aefe33000497d8"
|
|
42
42
|
}
|