@yagni-app/code-staging 0.3.0-staging.1093.1 → 0.3.0-staging.1098.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/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +42 -2
- 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/index.d.ts +15 -0
- package/dist/extension/index.js +47 -3
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/pipeline/personas.js +1 -1
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +3 -2
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { canSurfaceSuggestion, noteSuggestionSurfaced } from "./flywheel.js";
|
|
3
4
|
import { collectRepoDocs } from "./repoDocs.js";
|
|
4
5
|
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
5
6
|
import { markdownOrPlain } from "./subagentRender.js";
|
|
7
|
+
/** One line the TUI shows above the answer, per standing. */
|
|
8
|
+
export const STANDING_LINES = {
|
|
9
|
+
confirmed: "Grounded in a confirmed decision",
|
|
10
|
+
asserted: "Grounded in a recorded assumption, not yet verified",
|
|
11
|
+
inferred: "Inferred from workspace context, not a recorded decision",
|
|
12
|
+
no_position: "No recorded position in this workspace",
|
|
13
|
+
};
|
|
14
|
+
function standingLine(value) {
|
|
15
|
+
return typeof value === "string" && value in STANDING_LINES
|
|
16
|
+
? STANDING_LINES[value]
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
6
19
|
const parameters = Type.Object({
|
|
7
20
|
question: Type.String(),
|
|
8
21
|
context: Type.Optional(Type.String()),
|
|
@@ -40,6 +53,7 @@ export function makeAskYagniTool(opts) {
|
|
|
40
53
|
"Call ask_yagni BEFORE guessing about anything organization- or codebase-specific (conventions, policies, architecture, ownership, product decisions).",
|
|
41
54
|
"Pass the user's actual question; add relevant local context (file paths, snippets) in the optional `context` field.",
|
|
42
55
|
"When you use an answer, quote or reference its citations so the user can verify the source.",
|
|
56
|
+
"Answers carry a standing: treat a confirmed decision as settled; when you lean on an unverified assumption or an inference, say so where the work is reviewed; when there is no recorded position, follow the answer's instruction to record the assumption you proceed on.",
|
|
43
57
|
],
|
|
44
58
|
parameters,
|
|
45
59
|
renderCall(args, theme) {
|
|
@@ -55,8 +69,11 @@ export function makeAskYagniTool(opts) {
|
|
|
55
69
|
const citations = result.details?.citations ?? [];
|
|
56
70
|
if (isPartial)
|
|
57
71
|
return new Text(t.fg("muted", answer || "Asking YAGNI…"), 0, 0);
|
|
72
|
+
const standing = standingLine(result.details?.standing);
|
|
58
73
|
if (expanded) {
|
|
59
74
|
const container = new Container();
|
|
75
|
+
if (standing)
|
|
76
|
+
container.addChild(new Text(t.fg("muted", standing), 0, 0));
|
|
60
77
|
container.addChild(markdownOrPlain(answer || "(no answer)", t));
|
|
61
78
|
if (citations.length > 0) {
|
|
62
79
|
container.addChild(new Spacer(1));
|
|
@@ -69,6 +86,8 @@ export function makeAskYagniTool(opts) {
|
|
|
69
86
|
const lines = answer.trim().split("\n");
|
|
70
87
|
const out = lines.slice(0, ANSWER_PREVIEW_LINES).map((l) => t.fg("toolOutput", l));
|
|
71
88
|
const meta = [];
|
|
89
|
+
if (standing)
|
|
90
|
+
meta.push(standing);
|
|
72
91
|
if (citations.length > 0)
|
|
73
92
|
meta.push(citationCount(citations.length));
|
|
74
93
|
if (lines.length > ANSWER_PREVIEW_LINES || citations.length > 0)
|
|
@@ -95,6 +114,7 @@ export function makeAskYagniTool(opts) {
|
|
|
95
114
|
question: params.question,
|
|
96
115
|
context: params.context,
|
|
97
116
|
cwd: ctx?.cwd,
|
|
117
|
+
...(opts.getRepo?.() ? { repo: opts.getRepo() } : {}),
|
|
98
118
|
...(repoDocs.length > 0 ? { repoDocs } : {}),
|
|
99
119
|
}),
|
|
100
120
|
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
@@ -102,9 +122,29 @@ export function makeAskYagniTool(opts) {
|
|
|
102
122
|
throw new Error(await friendlyFetchError("ask_yagni", res));
|
|
103
123
|
}
|
|
104
124
|
const data = (await res.json());
|
|
125
|
+
// Run 7 flywheel: a no-position answer carries the record-the-assumption
|
|
126
|
+
// instruction. It reaches the model VERBATIM inside the tool result, but
|
|
127
|
+
// only up to the per-session cap — past it a busy run stops being told
|
|
128
|
+
// to bank more asserted rows.
|
|
129
|
+
let text = data.answer;
|
|
130
|
+
const suggestion = data.recordSuggestion?.instruction;
|
|
131
|
+
if (suggestion && data.standing === "no_position") {
|
|
132
|
+
const state = opts.flywheel;
|
|
133
|
+
if (!state || canSurfaceSuggestion(state)) {
|
|
134
|
+
// Attribution correlates to the QUESTION the suggestion asked
|
|
135
|
+
// about, so only the record_decision that answers it inherits the
|
|
136
|
+
// dedupe flag.
|
|
137
|
+
if (state)
|
|
138
|
+
noteSuggestionSurfaced(state, data.recordSuggestion?.question ?? params.question);
|
|
139
|
+
text = `${text}\n\n${suggestion}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
105
142
|
return {
|
|
106
|
-
content: [{ type: "text", text
|
|
107
|
-
details: {
|
|
143
|
+
content: [{ type: "text", text }],
|
|
144
|
+
details: {
|
|
145
|
+
citations: data.citations ?? [],
|
|
146
|
+
...(data.standing ? { standing: data.standing } : {}),
|
|
147
|
+
},
|
|
108
148
|
};
|
|
109
149
|
},
|
|
110
150
|
};
|
|
@@ -113,6 +113,12 @@ export interface ContextBrief {
|
|
|
113
113
|
decisions: number;
|
|
114
114
|
corrections: number;
|
|
115
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* Active decisions scoped to the repo named on the request (additive, Run
|
|
118
|
+
* 7; absent without `?repo=`, on older backends, and on a count failure).
|
|
119
|
+
* The opt-in mining beat fires ONLY on an explicit 0.
|
|
120
|
+
*/
|
|
121
|
+
repoDecisionCount?: number;
|
|
116
122
|
}
|
|
117
123
|
/**
|
|
118
124
|
* Attribution headers for the model proxy (YAG-471). On the SERVER side these
|
|
@@ -192,6 +198,12 @@ export interface FetchContextBriefOptions {
|
|
|
192
198
|
fetchImpl?: typeof fetch;
|
|
193
199
|
/** Env seam for the telemetry headers; defaults to process.env. */
|
|
194
200
|
env?: NodeJS.ProcessEnv;
|
|
201
|
+
/**
|
|
202
|
+
* The session repo (`owner/name`), when resolvable. Rides as `?repo=` so
|
|
203
|
+
* the response can carry `repoDecisionCount` for the mining beat — no
|
|
204
|
+
* second round-trip at boot.
|
|
205
|
+
*/
|
|
206
|
+
repo?: string;
|
|
195
207
|
}
|
|
196
208
|
/**
|
|
197
209
|
* Fetch the workspace company brief at startup.
|
package/dist/extension/config.js
CHANGED
|
@@ -208,7 +208,8 @@ export function sessionTelemetryHeaders(env = process.env) {
|
|
|
208
208
|
*/
|
|
209
209
|
export async function fetchContextBrief(opts) {
|
|
210
210
|
try {
|
|
211
|
-
const
|
|
211
|
+
const query = opts.repo ? `?repo=${encodeURIComponent(opts.repo)}` : "";
|
|
212
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context${query}`, {
|
|
212
213
|
method: "GET",
|
|
213
214
|
headers: {
|
|
214
215
|
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
@@ -35,7 +35,18 @@
|
|
|
35
35
|
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
36
36
|
*/
|
|
37
37
|
export type TokenEntry = string | {
|
|
38
|
-
op: "pipe" | "and" | "or" | "semi" | "
|
|
38
|
+
op: "pipe" | "and" | "or" | "semi" | "substitution";
|
|
39
|
+
} | {
|
|
40
|
+
op: "redirect";
|
|
41
|
+
direction: "out";
|
|
42
|
+
fd: "stdout" | "stderr";
|
|
43
|
+
target: string;
|
|
44
|
+
append: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
op: "redirect";
|
|
47
|
+
direction: "in";
|
|
48
|
+
} | {
|
|
49
|
+
op: "background";
|
|
39
50
|
};
|
|
40
51
|
/**
|
|
41
52
|
* Parse a shell command string into tokens and control operators.
|
|
@@ -48,6 +59,11 @@ export type TokenEntry = string | {
|
|
|
48
59
|
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
49
60
|
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
50
61
|
* inside double quotes — bash executes those), >, <, background &
|
|
62
|
+
* - Redirect metadata: stdout/stderr redirects carry fd + target so that
|
|
63
|
+
* safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
|
|
64
|
+
* (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
|
|
65
|
+
* floor. Background & emits a distinct `background` op (not `semi`) so
|
|
66
|
+
* hasUnhandledConstructs can always catch it.
|
|
51
67
|
*
|
|
52
68
|
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
53
69
|
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
46
46
|
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
47
47
|
* inside double quotes — bash executes those), >, <, background &
|
|
48
|
+
* - Redirect metadata: stdout/stderr redirects carry fd + target so that
|
|
49
|
+
* safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
|
|
50
|
+
* (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
|
|
51
|
+
* floor. Background & emits a distinct `background` op (not `semi`) so
|
|
52
|
+
* hasUnhandledConstructs can always catch it.
|
|
48
53
|
*
|
|
49
54
|
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
50
55
|
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
@@ -149,11 +154,13 @@ export function shellParse(command) {
|
|
|
149
154
|
i += 2;
|
|
150
155
|
}
|
|
151
156
|
else {
|
|
152
|
-
// Single & — background operator.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
157
|
+
// Single & — background operator. Emits a distinct `background` op
|
|
158
|
+
// (not `semi`) so hasUnhandledConstructs can always catch it and
|
|
159
|
+
// floor the command. The command before it must still be rule-
|
|
160
|
+
// matched: `rm -rf / &` has to stay forbidden, so emit a separator
|
|
161
|
+
// rather than gluing.
|
|
155
162
|
pushCurrent();
|
|
156
|
-
tokens.push({ op: "
|
|
163
|
+
tokens.push({ op: "background" });
|
|
157
164
|
hasConstruct = true;
|
|
158
165
|
i++;
|
|
159
166
|
}
|
|
@@ -163,18 +170,124 @@ export function shellParse(command) {
|
|
|
163
170
|
tokens.push({ op: "semi" });
|
|
164
171
|
i++;
|
|
165
172
|
continue;
|
|
166
|
-
case ">":
|
|
167
|
-
case "<":
|
|
173
|
+
case ">": {
|
|
168
174
|
pushCurrent();
|
|
169
|
-
|
|
175
|
+
// Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
|
|
176
|
+
// The digit was emitted as a string token — pop it and use as fd.
|
|
177
|
+
let fd = "stdout";
|
|
178
|
+
if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
|
|
179
|
+
const last = tokens[tokens.length - 1];
|
|
180
|
+
if (last === "2") {
|
|
181
|
+
fd = "stderr";
|
|
182
|
+
tokens.pop();
|
|
183
|
+
}
|
|
184
|
+
else if (last === "1") {
|
|
185
|
+
fd = "stdout";
|
|
186
|
+
tokens.pop();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let append = false;
|
|
190
|
+
i++;
|
|
191
|
+
if (command[i] === ">") {
|
|
192
|
+
append = true;
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
while (command[i] === " " || command[i] === "\t")
|
|
196
|
+
i++;
|
|
197
|
+
// Read the target — handles quoted targets (mirrors the main loop's
|
|
198
|
+
// quote logic), fd merges (&N), and bare words.
|
|
199
|
+
let target = "";
|
|
200
|
+
if (command[i] === "&") {
|
|
201
|
+
// fd merge: &1, &2, etc.
|
|
202
|
+
i++;
|
|
203
|
+
let digits = "";
|
|
204
|
+
while (command[i] >= "0" && command[i] <= "9") {
|
|
205
|
+
digits += command[i];
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
target = "&" + digits;
|
|
209
|
+
}
|
|
210
|
+
else if (command[i] === "'") {
|
|
211
|
+
i++;
|
|
212
|
+
while (i < command.length && command[i] !== "'") {
|
|
213
|
+
target += command[i];
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
if (i < command.length)
|
|
217
|
+
i++;
|
|
218
|
+
}
|
|
219
|
+
else if (command[i] === '"') {
|
|
220
|
+
i++;
|
|
221
|
+
while (i < command.length && command[i] !== '"') {
|
|
222
|
+
if (command[i] === "\\" && i + 1 < command.length) {
|
|
223
|
+
target += command[i + 1];
|
|
224
|
+
i += 2;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
target += command[i];
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (i < command.length)
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
while (i < command.length &&
|
|
236
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
237
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
238
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
239
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
240
|
+
command[i] !== "<") {
|
|
241
|
+
target += command[i];
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
tokens.push({ op: "redirect", direction: "out", fd, target, append });
|
|
170
246
|
hasConstruct = true;
|
|
171
|
-
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
case "<": {
|
|
250
|
+
pushCurrent();
|
|
251
|
+
// Stdin redirect — always floors (changes program behavior).
|
|
172
252
|
i++;
|
|
173
|
-
if (command[i] ===
|
|
174
|
-
i++; //
|
|
253
|
+
if (command[i] === "<")
|
|
254
|
+
i++; // << heredoc — skip delimiter
|
|
175
255
|
while (command[i] === " " || command[i] === "\t")
|
|
176
256
|
i++;
|
|
257
|
+
// Consume the target (filename or heredoc delimiter) so it doesn't
|
|
258
|
+
// appear as a segment token — mirrors the > case's target reading.
|
|
259
|
+
if (command[i] === "'") {
|
|
260
|
+
i++;
|
|
261
|
+
while (i < command.length && command[i] !== "'")
|
|
262
|
+
i++;
|
|
263
|
+
if (i < command.length)
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
else if (command[i] === '"') {
|
|
267
|
+
i++;
|
|
268
|
+
while (i < command.length && command[i] !== '"') {
|
|
269
|
+
if (command[i] === "\\" && i + 1 < command.length)
|
|
270
|
+
i += 2;
|
|
271
|
+
else
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
if (i < command.length)
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
while (i < command.length &&
|
|
279
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
280
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
281
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
282
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
283
|
+
command[i] !== "<") {
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
tokens.push({ op: "redirect", direction: "in" });
|
|
288
|
+
hasConstruct = true;
|
|
177
289
|
continue;
|
|
290
|
+
}
|
|
178
291
|
case "$":
|
|
179
292
|
if (command[i + 1] === "(") {
|
|
180
293
|
pushCurrent();
|
|
@@ -213,9 +326,10 @@ export function shellParse(command) {
|
|
|
213
326
|
}
|
|
214
327
|
pushCurrent();
|
|
215
328
|
// If we detected constructs but didn't emit them as operator tokens
|
|
216
|
-
// (e.g.
|
|
217
|
-
//
|
|
218
|
-
|
|
329
|
+
// (e.g. double-quoted substitution), surface that via a trailing
|
|
330
|
+
// substitution token so hasUnhandledConstructs sees it. Background & now
|
|
331
|
+
// emits its own distinct op, so it no longer relies on this fallback.
|
|
332
|
+
if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
|
|
219
333
|
tokens.push({ op: "substitution" });
|
|
220
334
|
}
|
|
221
335
|
return tokens;
|
|
@@ -294,47 +408,58 @@ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
|
294
408
|
export function tokenize(command) {
|
|
295
409
|
return shellParse(command).filter((t) => typeof t === "string");
|
|
296
410
|
}
|
|
297
|
-
/**
|
|
298
|
-
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
411
|
+
/** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
|
|
412
|
+
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
|
|
413
|
+
/** Ops that are safe splittable separators — they do NOT trigger the construct floor.
|
|
414
|
+
* Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
|
|
415
|
+
const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
416
|
+
/** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
|
|
417
|
+
function isSafeRedirect(t) {
|
|
418
|
+
if (typeof t === "string")
|
|
419
|
+
return false;
|
|
420
|
+
if (t.op !== "redirect")
|
|
421
|
+
return false;
|
|
422
|
+
if (t.direction === "in")
|
|
423
|
+
return false; // stdin redirect — always floor
|
|
424
|
+
if (t.target === "/dev/null")
|
|
425
|
+
return true; // discard stderr/stdout — safe
|
|
426
|
+
if (t.target.startsWith("&") && t.target.length > 1)
|
|
427
|
+
return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
|
|
428
|
+
return false; // > file.txt, >> file.txt — unsafe
|
|
429
|
+
}
|
|
299
430
|
/**
|
|
300
431
|
* Detect whether the command uses shell constructs we can't statically
|
|
301
|
-
* classify (command substitution, redirects, background &) — anything
|
|
302
|
-
* is NOT a splittable operator
|
|
303
|
-
* carrying them is never auto-allowed, but
|
|
432
|
+
* classify (command substitution, unsafe redirects, background &) — anything
|
|
433
|
+
* that is NOT a safe splittable operator or a safe redirect. These impose a
|
|
434
|
+
* floor of `prompt`: a command carrying them is never auto-allowed, but
|
|
435
|
+
* forbidden matches still win.
|
|
304
436
|
*/
|
|
305
437
|
function hasUnhandledConstructs(command) {
|
|
306
|
-
return shellParse(command).some((t) => typeof t === "object" &&
|
|
438
|
+
return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
|
|
307
439
|
}
|
|
308
440
|
/**
|
|
309
441
|
* Split a command into token-array segments at control operators (|, &&, ||,
|
|
310
|
-
* ;, newline). Redirect targets
|
|
311
|
-
*
|
|
312
|
-
* carried through (never re-joined into strings)
|
|
442
|
+
* ;, newline, background &). Redirect targets are consumed inside the
|
|
443
|
+
* tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
|
|
444
|
+
* is needed. Token arrays are carried through (never re-joined into strings)
|
|
445
|
+
* so quoting survives.
|
|
313
446
|
*/
|
|
314
447
|
function splitSegmentsTokens(command) {
|
|
315
448
|
const parsed = shellParse(command);
|
|
316
449
|
const segments = [];
|
|
317
450
|
let current = [];
|
|
318
|
-
let skipNext = false;
|
|
319
451
|
for (const t of parsed) {
|
|
320
452
|
if (typeof t === "object") {
|
|
321
453
|
if (SPLIT_OPS.has(t.op)) {
|
|
322
454
|
if (current.length > 0)
|
|
323
455
|
segments.push(current);
|
|
324
456
|
current = [];
|
|
325
|
-
skipNext = false;
|
|
326
|
-
}
|
|
327
|
-
else if (t.op === "redirect") {
|
|
328
|
-
skipNext = true;
|
|
329
457
|
}
|
|
330
|
-
// substitution ops are construct markers;
|
|
331
|
-
//
|
|
458
|
+
// redirect and substitution ops are construct markers; their targets
|
|
459
|
+
// are consumed inside the tokenizer, and the inner text of $(...) is
|
|
460
|
+
// handled by dangerScan via extractSubstitutions.
|
|
332
461
|
}
|
|
333
462
|
else {
|
|
334
|
-
if (skipNext) {
|
|
335
|
-
skipNext = false;
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
463
|
current.push(t);
|
|
339
464
|
}
|
|
340
465
|
}
|
|
@@ -510,6 +635,9 @@ function classifySegmentTokens(rawTokens, policy, opts) {
|
|
|
510
635
|
const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
|
|
511
636
|
if (tailResult.decision === "forbidden")
|
|
512
637
|
return tailResult;
|
|
638
|
+
if (tailResult.decision === "allow" && !neverAllow) {
|
|
639
|
+
return { decision: "allow", justification: "xargs forwards to a read-only command" };
|
|
640
|
+
}
|
|
513
641
|
}
|
|
514
642
|
if (opts.forbiddenOnly)
|
|
515
643
|
return { decision: "allow", justification: "no forbidden match" };
|
|
@@ -727,6 +855,7 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
727
855
|
{ pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
|
|
728
856
|
{ pattern: ["stat"], decision: "allow", justification: "show file metadata" },
|
|
729
857
|
{ pattern: ["file"], decision: "allow", justification: "identify file type" },
|
|
858
|
+
{ pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
|
|
730
859
|
{ pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
|
|
731
860
|
{ pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
|
|
732
861
|
{ pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
|
|
@@ -760,7 +889,9 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
760
889
|
{ pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
|
|
761
890
|
{ pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
|
|
762
891
|
{ pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
892
|
+
{ pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
|
|
763
893
|
{ pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
894
|
+
{ pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
|
|
764
895
|
{ pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
|
|
765
896
|
{ pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
|
|
766
897
|
// --- prompt: potentially destructive but context-dependent ---
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session state for the miss-to-record flywheel (org plane, Run 7).
|
|
3
|
+
*
|
|
4
|
+
* When `ask_yagni` answers `no_position`, the server attaches a
|
|
5
|
+
* `recordSuggestion` telling the agent to bank the assumption it proceeds on.
|
|
6
|
+
* The extension owns two client-side guards the server cannot:
|
|
7
|
+
*
|
|
8
|
+
* - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
|
|
9
|
+
* surfaced per session; past it the suggestion is suppressed so a busy
|
|
10
|
+
* session cannot flood the ledger with asserted rows.
|
|
11
|
+
* - **Dedupe attribution.** A `record_decision` call that answers the
|
|
12
|
+
* QUESTION a surfaced suggestion asked about is a mid-run agent with no
|
|
13
|
+
* human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
|
|
14
|
+
* backend then returns the existing row instead of inserting). The
|
|
15
|
+
* attribution is correlated to the suggested question — an unrelated
|
|
16
|
+
* record_decision (a different judgment the agent banks mid-run, or a
|
|
17
|
+
* human `/decide`) never inherits the flag.
|
|
18
|
+
*/
|
|
19
|
+
/** Most flywheel suggestions surfaced per session. */
|
|
20
|
+
export declare const FLYWHEEL_RUN_CAP = 3;
|
|
21
|
+
export interface FlywheelState {
|
|
22
|
+
/** Suggestions surfaced so far this session. */
|
|
23
|
+
suggestionsShown: number;
|
|
24
|
+
/**
|
|
25
|
+
* The question of the most recent surfaced suggestion, until a matching
|
|
26
|
+
* record_decision consumes it. Only a record answering THIS question is
|
|
27
|
+
* flywheel-attributed (sends `dedupe: true`).
|
|
28
|
+
*/
|
|
29
|
+
pendingQuestion: string | null;
|
|
30
|
+
}
|
|
31
|
+
export declare function makeFlywheelState(): FlywheelState;
|
|
32
|
+
/** May another suggestion be surfaced? */
|
|
33
|
+
export declare function canSurfaceSuggestion(state: FlywheelState): boolean;
|
|
34
|
+
/** Record that a suggestion (asking about `question`) reached the model. */
|
|
35
|
+
export declare function noteSuggestionSurfaced(state: FlywheelState, question: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Consume the flywheel attribution for a record_decision call. Returns true
|
|
38
|
+
* (and clears the pending question) only when the recorded question matches
|
|
39
|
+
* the surfaced suggestion's; a mismatch leaves the attribution pending — the
|
|
40
|
+
* agent may record other judgments before circling back, and a fail-safe
|
|
41
|
+
* mismatch simply means a normal insert (no dedupe), never a swallowed write.
|
|
42
|
+
*/
|
|
43
|
+
export declare function consumeFlywheelAttribution(state: FlywheelState, recordedQuestion: string): boolean;
|
|
44
|
+
//# sourceMappingURL=flywheel.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session state for the miss-to-record flywheel (org plane, Run 7).
|
|
3
|
+
*
|
|
4
|
+
* When `ask_yagni` answers `no_position`, the server attaches a
|
|
5
|
+
* `recordSuggestion` telling the agent to bank the assumption it proceeds on.
|
|
6
|
+
* The extension owns two client-side guards the server cannot:
|
|
7
|
+
*
|
|
8
|
+
* - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
|
|
9
|
+
* surfaced per session; past it the suggestion is suppressed so a busy
|
|
10
|
+
* session cannot flood the ledger with asserted rows.
|
|
11
|
+
* - **Dedupe attribution.** A `record_decision` call that answers the
|
|
12
|
+
* QUESTION a surfaced suggestion asked about is a mid-run agent with no
|
|
13
|
+
* human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
|
|
14
|
+
* backend then returns the existing row instead of inserting). The
|
|
15
|
+
* attribution is correlated to the suggested question — an unrelated
|
|
16
|
+
* record_decision (a different judgment the agent banks mid-run, or a
|
|
17
|
+
* human `/decide`) never inherits the flag.
|
|
18
|
+
*/
|
|
19
|
+
/** Most flywheel suggestions surfaced per session. */
|
|
20
|
+
export const FLYWHEEL_RUN_CAP = 3;
|
|
21
|
+
export function makeFlywheelState() {
|
|
22
|
+
return { suggestionsShown: 0, pendingQuestion: null };
|
|
23
|
+
}
|
|
24
|
+
/** May another suggestion be surfaced? */
|
|
25
|
+
export function canSurfaceSuggestion(state) {
|
|
26
|
+
return state.suggestionsShown < FLYWHEEL_RUN_CAP;
|
|
27
|
+
}
|
|
28
|
+
/** Loose textual identity: case- and whitespace-insensitive, terminal punctuation ignored. */
|
|
29
|
+
function normalizeQuestion(question) {
|
|
30
|
+
return question.toLowerCase().replace(/\s+/g, " ").replace(/[.?!\s]+$/g, "").trim();
|
|
31
|
+
}
|
|
32
|
+
/** Record that a suggestion (asking about `question`) reached the model. */
|
|
33
|
+
export function noteSuggestionSurfaced(state, question) {
|
|
34
|
+
state.suggestionsShown += 1;
|
|
35
|
+
state.pendingQuestion = question;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Consume the flywheel attribution for a record_decision call. Returns true
|
|
39
|
+
* (and clears the pending question) only when the recorded question matches
|
|
40
|
+
* the surfaced suggestion's; a mismatch leaves the attribution pending — the
|
|
41
|
+
* agent may record other judgments before circling back, and a fail-safe
|
|
42
|
+
* mismatch simply means a normal insert (no dedupe), never a swallowed write.
|
|
43
|
+
*/
|
|
44
|
+
export function consumeFlywheelAttribution(state, recordedQuestion) {
|
|
45
|
+
if (state.pendingQuestion === null)
|
|
46
|
+
return false;
|
|
47
|
+
if (normalizeQuestion(state.pendingQuestion) !== normalizeQuestion(recordedQuestion)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
state.pendingQuestion = null;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=flywheel.js.map
|
|
@@ -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
|
package/dist/extension/index.js
CHANGED
|
@@ -41,6 +41,8 @@ import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
|
|
|
41
41
|
import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
|
|
42
42
|
import { buildYagniProvider } from "./provider.js";
|
|
43
43
|
import { registerChipEditor } from "./chipEditor.js";
|
|
44
|
+
import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
|
|
45
|
+
import { makeFlywheelState } from "./flywheel.js";
|
|
44
46
|
function isEvalMode(env = process.env) {
|
|
45
47
|
return env.YAGNI_CODE_EVAL_MODE === "1";
|
|
46
48
|
}
|
|
@@ -144,7 +146,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
144
146
|
// child, a subagent, or an advisor consult).
|
|
145
147
|
pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
|
|
146
148
|
const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
|
|
147
|
-
|
|
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 }));
|
|
148
154
|
// Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
|
|
149
155
|
// workspace tracker, attributed to the developer via per-user credentials.
|
|
150
156
|
if (!evalMode) {
|
|
@@ -172,7 +178,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
172
178
|
pi.registerTool(makeRecordEngineeringContextTool(toolOpts));
|
|
173
179
|
// The capture half of the judgment loop: bank a product-intent decision so
|
|
174
180
|
// ask_yagni answers the same question next time instead of interrupting a human.
|
|
175
|
-
pi.registerTool(makeRecordDecisionTool(toolOpts));
|
|
181
|
+
pi.registerTool(makeRecordDecisionTool({ ...toolOpts, flywheel: flywheelState }));
|
|
176
182
|
}
|
|
177
183
|
// The visible checklist for multi-step work: the todo_write tool, its
|
|
178
184
|
// above-editor widget, and /todos. Branch-replayed, so forks and resumes
|
|
@@ -460,11 +466,27 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
460
466
|
// must never block the agent), then inject it into the system prompt so the
|
|
461
467
|
// very first turn already knows how this company and codebase work.
|
|
462
468
|
const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
|
|
469
|
+
// Resolved BEFORE the brief fetch so /context can answer repoDecisionCount
|
|
470
|
+
// in the same round-trip (the mining beat's signal). Best-effort: undefined
|
|
471
|
+
// outside a git repo or without an origin remote.
|
|
472
|
+
const resolveRepoFn = deps.resolveRepoFullName ?? ((cwd) => defaultMineBeatGit.repoFullName(cwd));
|
|
473
|
+
let sessionRepo;
|
|
474
|
+
try {
|
|
475
|
+
sessionRepo = evalMode ? undefined : resolveRepoFn(process.cwd());
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
sessionRepo = undefined;
|
|
479
|
+
}
|
|
463
480
|
let contextBrief;
|
|
464
481
|
let briefResult = null;
|
|
465
482
|
if (!evalMode) {
|
|
466
483
|
try {
|
|
467
|
-
briefResult = await fetchBrief({
|
|
484
|
+
briefResult = await fetchBrief({
|
|
485
|
+
baseUrl,
|
|
486
|
+
getToken: getTokenFn,
|
|
487
|
+
fetchImpl: authedFetch,
|
|
488
|
+
repo: sessionRepo,
|
|
489
|
+
});
|
|
468
490
|
contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
|
|
469
491
|
}
|
|
470
492
|
catch {
|
|
@@ -769,6 +791,28 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
769
791
|
// The init pass is best-effort; a failure never blocks the session.
|
|
770
792
|
}
|
|
771
793
|
}
|
|
794
|
+
// Run 7: the opt-in repo seeding beat. Fires only on a genuine startup
|
|
795
|
+
// when /context reported an explicitly-empty repo-scoped ledger, once per
|
|
796
|
+
// repo (marker store). Fully fail-soft — a beat failure never blocks the
|
|
797
|
+
// session.
|
|
798
|
+
if (event.reason === "startup" && !evalMode) {
|
|
799
|
+
try {
|
|
800
|
+
const offerBeat = deps.offerMiningBeat ?? defaultMaybeOfferMiningBeat;
|
|
801
|
+
const markers = deps.mineBeatMarkers ?? fileMineBeatMarkers(codeStateHome(null, env));
|
|
802
|
+
await offerBeat(ctx, {
|
|
803
|
+
repoFullName: sessionRepo,
|
|
804
|
+
repoDecisionCount: briefResult?.repoDecisionCount,
|
|
805
|
+
cwd: process.cwd(),
|
|
806
|
+
baseUrl,
|
|
807
|
+
getToken: getTokenFn,
|
|
808
|
+
fetchImpl: authedFetch,
|
|
809
|
+
markers,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// Seeding is best-effort; a failure never blocks the session.
|
|
814
|
+
}
|
|
815
|
+
}
|
|
772
816
|
});
|
|
773
817
|
}
|
|
774
818
|
export default async function (pi) {
|