@yagni-app/code 0.3.0 → 0.3.2
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/crashReport.d.ts +12 -0
- package/dist/crashReport.js +28 -1
- package/dist/extension/approvedPrefixes.d.ts +11 -0
- package/dist/extension/approvedPrefixes.js +30 -0
- package/dist/extension/askAdvisorTool.d.ts +18 -3
- package/dist/extension/askAdvisorTool.js +121 -15
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +42 -2
- package/dist/extension/branding.d.ts +11 -1
- package/dist/extension/branding.js +47 -7
- package/dist/extension/config.d.ts +12 -0
- package/dist/extension/config.js +2 -1
- package/dist/extension/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +35 -2
- package/dist/extension/execPolicy.d.ts +17 -1
- package/dist/extension/execPolicy.js +227 -33
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +8 -1
- package/dist/extension/footer.js +33 -19
- package/dist/extension/guardian.d.ts +14 -4
- package/dist/extension/guardian.js +35 -11
- package/dist/extension/index.d.ts +20 -3
- package/dist/extension/index.js +92 -13
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +2 -1
- package/dist/extension/permission.js +75 -22
- package/dist/extension/pipeline/goCommand.js +6 -4
- package/dist/extension/pipeline/invocation.d.ts +24 -2
- package/dist/extension/pipeline/invocation.js +30 -2
- package/dist/extension/pipeline/personas.js +2 -2
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +9 -1
- package/dist/extension/pipeline/runRegistry.js +22 -1
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/subagents.d.ts +7 -1
- package/dist/extension/subagents.js +73 -5
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +1 -1
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in repo seeding beat (org plane, Run 7).
|
|
3
|
+
*
|
|
4
|
+
* On the first interactive session in a repo whose repo-scoped decision
|
|
5
|
+
* ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
|
|
6
|
+
* Code offers — once — to read the repo's own decision record and seed the
|
|
7
|
+
* ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
|
|
8
|
+
* One keystroke, then the backend extracts, dedupes, and banks at most 12
|
|
9
|
+
* `repo_mined` asserted rows, and the beat prints the banked count plus the
|
|
10
|
+
* Library review deep link.
|
|
11
|
+
*
|
|
12
|
+
* Never automatic, never a nag: a decline writes a per-repo marker so the
|
|
13
|
+
* beat does not recur; an accepted pass marks the repo only after the server
|
|
14
|
+
* confirms (a transport failure leaves the offer available next session).
|
|
15
|
+
* The corpus is collected at git HEAD (same echo-chamber guard as
|
|
16
|
+
* repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
|
|
17
|
+
* docs/*.md, and recent merge subjects — bounded client-side to the same
|
|
18
|
+
* limits the backend enforces.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
24
|
+
/**
|
|
25
|
+
* Client-side bounds on the mining corpus — mirror of the backend's
|
|
26
|
+
* MINE_INPUT_LIMITS so nothing is silently truncated server-side.
|
|
27
|
+
*/
|
|
28
|
+
export const MINE_BEAT_LIMITS = {
|
|
29
|
+
maxDocs: 40,
|
|
30
|
+
maxPathChars: 300,
|
|
31
|
+
maxExcerptChars: 8_000,
|
|
32
|
+
maxCommitSubjects: 200,
|
|
33
|
+
maxSubjectChars: 200,
|
|
34
|
+
};
|
|
35
|
+
const GIT_OPTS = {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
};
|
|
40
|
+
function git(cwd, args) {
|
|
41
|
+
try {
|
|
42
|
+
return execFileSync("git", args, { cwd, ...GIT_OPTS });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Parse `owner/name` from an origin remote URL (ssh or https). */
|
|
49
|
+
export function parseRepoFullName(remote) {
|
|
50
|
+
if (!remote)
|
|
51
|
+
return undefined;
|
|
52
|
+
const m = remote.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
53
|
+
return m ? m[1] : undefined;
|
|
54
|
+
}
|
|
55
|
+
export const defaultMineBeatGit = {
|
|
56
|
+
repoFullName: (cwd) => parseRepoFullName(git(cwd, ["config", "--get", "remote.origin.url"])),
|
|
57
|
+
listAtHead: (cwd) => {
|
|
58
|
+
const out = git(cwd, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
59
|
+
return out === null ? null : out.split("\n").filter(Boolean);
|
|
60
|
+
},
|
|
61
|
+
readAtHead: (cwd, rel) => git(cwd, ["show", `HEAD:${rel}`]),
|
|
62
|
+
mergeSubjects: (cwd, max) => {
|
|
63
|
+
const merges = git(cwd, ["log", "--merges", `-n`, String(max), "--pretty=%s"]);
|
|
64
|
+
const chosen = merges && merges.trim().length > 0
|
|
65
|
+
? merges
|
|
66
|
+
: // Squash-merge repos have no merge commits; plain subjects still
|
|
67
|
+
// carry the shipped-work trail. No further heuristics (spec flag).
|
|
68
|
+
git(cwd, ["log", `-n`, String(max), "--pretty=%s"]);
|
|
69
|
+
return (chosen ?? "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, max);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** Is this HEAD-tracked path part of the repo's decision record? */
|
|
73
|
+
export function isMineCandidate(rel) {
|
|
74
|
+
if (/^(?:docs\/)?adrs?\/[^/]+\.md$/i.test(rel))
|
|
75
|
+
return true;
|
|
76
|
+
if (/^(?:AGENTS|CLAUDE|CONTRIBUTING|CONTEXT|ARCHITECTURE)\.md$/i.test(rel))
|
|
77
|
+
return true;
|
|
78
|
+
if (/^docs\/[^/]+\.md$/i.test(rel))
|
|
79
|
+
return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
|
|
83
|
+
export function collectMineCorpus(cwd, gitImpl = defaultMineBeatGit) {
|
|
84
|
+
const tracked = gitImpl.listAtHead(cwd);
|
|
85
|
+
if (tracked === null)
|
|
86
|
+
return { docs: [], commitSubjects: [] };
|
|
87
|
+
const docs = [];
|
|
88
|
+
for (const rel of tracked) {
|
|
89
|
+
if (docs.length >= MINE_BEAT_LIMITS.maxDocs)
|
|
90
|
+
break;
|
|
91
|
+
if (rel.length > MINE_BEAT_LIMITS.maxPathChars)
|
|
92
|
+
continue;
|
|
93
|
+
if (!isMineCandidate(rel))
|
|
94
|
+
continue;
|
|
95
|
+
const body = gitImpl.readAtHead(cwd, rel);
|
|
96
|
+
if (body === null || body.trim().length === 0)
|
|
97
|
+
continue;
|
|
98
|
+
docs.push({ path: rel, excerpt: body.trim().slice(0, MINE_BEAT_LIMITS.maxExcerptChars) });
|
|
99
|
+
}
|
|
100
|
+
const commitSubjects = gitImpl
|
|
101
|
+
.mergeSubjects(cwd, MINE_BEAT_LIMITS.maxCommitSubjects)
|
|
102
|
+
.map((s) => s.slice(0, MINE_BEAT_LIMITS.maxSubjectChars));
|
|
103
|
+
return { docs, commitSubjects };
|
|
104
|
+
}
|
|
105
|
+
/** Filesystem path of one repo's beat marker under the state home. */
|
|
106
|
+
export function mineMarkerPath(stateHome, repoFullName) {
|
|
107
|
+
return join(stateHome, "mine-beat", `${repoFullName.replace(/[/\\]/g, "__")}.json`);
|
|
108
|
+
}
|
|
109
|
+
export function fileMineBeatMarkers(stateHome) {
|
|
110
|
+
return {
|
|
111
|
+
has: (repo) => {
|
|
112
|
+
try {
|
|
113
|
+
return fs.existsSync(mineMarkerPath(stateHome, repo));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
write: (repo, status) => {
|
|
120
|
+
try {
|
|
121
|
+
const p = mineMarkerPath(stateHome, repo);
|
|
122
|
+
fs.mkdirSync(dirname(p), { recursive: true });
|
|
123
|
+
fs.writeFileSync(p, JSON.stringify({ status, at: new Date().toISOString() }), "utf8");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// A marker failure must never break the session; worst case the beat
|
|
127
|
+
// asks once more next launch.
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
|
|
134
|
+
* throughout: any error surfaces as a notify at most, never a broken session.
|
|
135
|
+
*/
|
|
136
|
+
export async function maybeOfferMiningBeat(ctx, opts) {
|
|
137
|
+
const { repoFullName } = opts;
|
|
138
|
+
// Only an EXPLICIT zero fires the beat: an absent count means an older
|
|
139
|
+
// backend or a count failure, and a nag on either would be wrong.
|
|
140
|
+
if (!repoFullName || opts.repoDecisionCount !== 0)
|
|
141
|
+
return { offered: false };
|
|
142
|
+
if (opts.markers.has(repoFullName))
|
|
143
|
+
return { offered: false };
|
|
144
|
+
if (!ctx.hasUI || typeof ctx.ui.confirm !== "function")
|
|
145
|
+
return { offered: false };
|
|
146
|
+
const collect = opts.collect ?? collectMineCorpus;
|
|
147
|
+
const corpus = collect(opts.cwd, opts.gitImpl ?? defaultMineBeatGit);
|
|
148
|
+
// Nothing to mine yet: skip WITHOUT a marker, so a later checkout that
|
|
149
|
+
// gains docs still gets its one offer.
|
|
150
|
+
if (corpus.docs.length === 0 && corpus.commitSubjects.length === 0) {
|
|
151
|
+
return { offered: false };
|
|
152
|
+
}
|
|
153
|
+
const accepted = await ctx.ui.confirm("Seed the decision ledger?", `Read this repo's ADRs and docs and bank up to 12 candidate decisions for ${repoFullName}? You review every one in the Library before it counts as confirmed.`);
|
|
154
|
+
if (!accepted) {
|
|
155
|
+
opts.markers.write(repoFullName, "declined");
|
|
156
|
+
return { offered: true, accepted: false };
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/decisions/mine`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: {
|
|
162
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
163
|
+
"content-type": "application/json",
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
repo: repoFullName,
|
|
167
|
+
docs: corpus.docs,
|
|
168
|
+
commitSubjects: corpus.commitSubjects,
|
|
169
|
+
// Stable per repo: the write-spool replay contract makes a retry of
|
|
170
|
+
// the same pass idempotent server-side.
|
|
171
|
+
idempotencyKey: `mine:${repoFullName}`,
|
|
172
|
+
}),
|
|
173
|
+
}, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
// No marker: the offer stays available next session.
|
|
176
|
+
ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
|
|
177
|
+
return { offered: true, accepted: true };
|
|
178
|
+
}
|
|
179
|
+
const body = (await res.json());
|
|
180
|
+
opts.markers.write(repoFullName, "seeded");
|
|
181
|
+
if (body.alreadyMined)
|
|
182
|
+
return { offered: true, accepted: true, banked: 0 };
|
|
183
|
+
const banked = typeof body.banked === "number" ? body.banked : 0;
|
|
184
|
+
const noun = banked === 1 ? "decision" : "decisions";
|
|
185
|
+
ctx.ui.notify(`Banked ${banked} ${noun} read from this repo's docs. Review them: ${opts.baseUrl}/knowledge/decisions`, "info");
|
|
186
|
+
return { offered: true, accepted: true, banked };
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
|
|
190
|
+
return { offered: true, accepted: true };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=mineBeat.js.map
|
|
@@ -41,6 +41,7 @@ export type PermissionMode = "auto" | "plan" | "review";
|
|
|
41
41
|
export interface ModeHolder {
|
|
42
42
|
get(): PermissionMode;
|
|
43
43
|
set(m: PermissionMode): void;
|
|
44
|
+
onSet(fn: (m: PermissionMode) => void): void;
|
|
44
45
|
}
|
|
45
46
|
export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
|
|
46
47
|
/** Which tools each tier acts on, plus the optional grounding-bless predicate. */
|
|
@@ -183,7 +184,7 @@ export interface RegisterPermissionDeps {
|
|
|
183
184
|
export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
184
185
|
/** Legacy alias — the original plan-mode tag, kept for backward compat. */
|
|
185
186
|
export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
|
|
186
|
-
export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and
|
|
187
|
+
export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.\n- Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.\n- write, edit, file_ticket, and update_ticket_status 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
188
|
/** Build the mode-awareness context message for the current permission mode. */
|
|
188
189
|
export declare function buildModeContextMessage(mode: PermissionMode): string;
|
|
189
190
|
/**
|
|
@@ -33,9 +33,15 @@ import { isDebug } from "./diagnostics.js";
|
|
|
33
33
|
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
34
34
|
export function createModeHolder(initial = "auto") {
|
|
35
35
|
let current = initial;
|
|
36
|
+
const listeners = new Set();
|
|
36
37
|
return {
|
|
37
38
|
get: () => current,
|
|
38
|
-
set: (m) => {
|
|
39
|
+
set: (m) => {
|
|
40
|
+
current = m;
|
|
41
|
+
for (const fn of listeners)
|
|
42
|
+
fn(m);
|
|
43
|
+
},
|
|
44
|
+
onSet: (fn) => { listeners.add(fn); },
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
export const DEFAULT_PERMISSION_POLICY = {
|
|
@@ -50,6 +56,42 @@ export const DEFAULT_PERMISSION_POLICY = {
|
|
|
50
56
|
*/
|
|
51
57
|
export function decideGate(toolName, params, mode, policy) {
|
|
52
58
|
if (mode === "plan") {
|
|
59
|
+
// Non-bash tools in planBlockTools are held outright — they are
|
|
60
|
+
// inherently mutating (write, edit, file_ticket, update_ticket_status).
|
|
61
|
+
// Bash is the exploration tool: run it through the exec policy so
|
|
62
|
+
// read-only commands (git status, ls, grep, gh pr view) work, and
|
|
63
|
+
// prompt-band commands are routed to the Guardian. The gate handler
|
|
64
|
+
// ensures Guardian-unavailable/capped/disabled states fail closed.
|
|
65
|
+
if (toolName === "bash") {
|
|
66
|
+
const command = typeof params.command === "string" ? params.command.trim() : "";
|
|
67
|
+
if (command) {
|
|
68
|
+
try {
|
|
69
|
+
const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
70
|
+
const classification = classifyCommand(command, execPolicy);
|
|
71
|
+
if (classification.decision === "allow")
|
|
72
|
+
return { block: false };
|
|
73
|
+
if (classification.decision === "forbidden") {
|
|
74
|
+
return {
|
|
75
|
+
block: true,
|
|
76
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// prompt — Guardian reviews. The gate handler runs the Guardian
|
|
80
|
+
// and handles allow/ask/deny. Grants and cache are skipped in
|
|
81
|
+
// plan mode (they can cover writes). Guardian unavailable/capped/
|
|
82
|
+
// disabled → block (fail closed).
|
|
83
|
+
return { block: false, classify: "prompt", classifyJustification: classification.justification };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// classifyCommand threw — fail closed in plan mode.
|
|
87
|
+
return {
|
|
88
|
+
block: true,
|
|
89
|
+
reason: `plan mode: could not classify this bash command and it is held. Switch to /mode auto to apply changes.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { block: false };
|
|
94
|
+
}
|
|
53
95
|
if (policy.planBlockTools.includes(toolName)) {
|
|
54
96
|
return {
|
|
55
97
|
block: true,
|
|
@@ -116,7 +158,9 @@ const AUTO_MARKER = "[AUTO MODE]";
|
|
|
116
158
|
const REVIEW_MARKER = "[REVIEW MODE]";
|
|
117
159
|
export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
|
|
118
160
|
You are in plan mode: explore and design, change nothing.
|
|
119
|
-
-
|
|
161
|
+
- Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.
|
|
162
|
+
- Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.
|
|
163
|
+
- write, edit, file_ticket, and update_ticket_status are held by the permission gate; do not attempt them.
|
|
120
164
|
- Read, search, and ask_yagni freely to ground the plan in how this company works.
|
|
121
165
|
- Produce a concrete numbered plan of the steps you would take, with the files involved.
|
|
122
166
|
- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
|
|
@@ -199,11 +243,6 @@ export function filterStaleModeContext(messages, currentMode) {
|
|
|
199
243
|
}
|
|
200
244
|
/** Legacy alias — the original plan-mode filter name. */
|
|
201
245
|
export const filterStalePlanContext = filterStaleModeContext;
|
|
202
|
-
const MODE_STATUS = {
|
|
203
|
-
auto: undefined,
|
|
204
|
-
plan: "⏸ plan",
|
|
205
|
-
review: "✓ review",
|
|
206
|
-
};
|
|
207
246
|
const MODE_COPY = {
|
|
208
247
|
auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
|
|
209
248
|
plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
|
|
@@ -257,6 +296,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
257
296
|
const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
|
|
258
297
|
let mode = deps.mode ?? "auto";
|
|
259
298
|
const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
|
|
299
|
+
deps.modeHolder?.onSet((m) => {
|
|
300
|
+
if (m !== mode)
|
|
301
|
+
approvedCommands.clear();
|
|
302
|
+
mode = m;
|
|
303
|
+
});
|
|
260
304
|
// The session bless store is created lazily on the first tool_call (it needs
|
|
261
305
|
// the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
|
|
262
306
|
// injected its own isBlessed (e.g. a test policy) — that always wins.
|
|
@@ -279,6 +323,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
279
323
|
const guardianTier = deps.guardianTier;
|
|
280
324
|
// --- YAG-510 gate state ---
|
|
281
325
|
// Grants: in-memory list seeded from deps, appended on "don't ask again".
|
|
326
|
+
// Deliberately NOT live-reloaded from disk: auto mode can write files, so a
|
|
327
|
+
// mid-session re-read of rules.json would let the agent (or a prompt
|
|
328
|
+
// injection) author its own grants and self-authorize within the same
|
|
329
|
+
// session. New grants from concurrent sessions apply at next launch — the
|
|
330
|
+
// startup load is the trust boundary (PR #1698 review).
|
|
282
331
|
const grants = [...(deps.grants ?? [])];
|
|
283
332
|
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
284
333
|
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
@@ -403,21 +452,24 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
403
452
|
}
|
|
404
453
|
}
|
|
405
454
|
// 2. Session exact-command approval cache (ticket 4.5).
|
|
406
|
-
|
|
455
|
+
// Skipped in plan mode: a cached approval can cover a write command,
|
|
456
|
+
// and plan mode's contract is no mutations without Guardian review.
|
|
457
|
+
if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
407
458
|
emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
|
|
408
459
|
return {};
|
|
409
460
|
}
|
|
410
461
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
411
462
|
const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
|
|
412
463
|
if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
464
|
+
// Sliding-window consult cap (capacity recovers as old reviews age
|
|
465
|
+
// out — a long-lived session is never bricked). Review mode falls
|
|
466
|
+
// through to its ordinary confirm (no LLM cost); auto and plan block.
|
|
467
|
+
if (modeAtEntry === "auto" || modeAtEntry === "plan") {
|
|
416
468
|
if (ctx?.hasUI)
|
|
417
|
-
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews}
|
|
418
|
-
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews}
|
|
469
|
+
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
|
|
470
|
+
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
471
|
}
|
|
420
|
-
// fall through to decision.confirm below
|
|
472
|
+
// review mode: fall through to decision.confirm below
|
|
421
473
|
}
|
|
422
474
|
else if (guardianAvailable) {
|
|
423
475
|
// Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
|
|
@@ -447,7 +499,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
447
499
|
}
|
|
448
500
|
// Show the reviewing chip.
|
|
449
501
|
if (ctx?.hasUI)
|
|
450
|
-
ctx.ui.setStatus?.("yagni-guardian", "
|
|
502
|
+
ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
|
|
451
503
|
const startMs = Date.now();
|
|
452
504
|
let reviewResult;
|
|
453
505
|
try {
|
|
@@ -663,6 +715,14 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
663
715
|
// approval cache were already consulted above.
|
|
664
716
|
return {};
|
|
665
717
|
}
|
|
718
|
+
else if (modeAtEntry === "plan") {
|
|
719
|
+
// Guardian disabled or not wired in plan mode: fail closed. Without
|
|
720
|
+
// the Guardian to verify the command is non-mutating, the plan-mode
|
|
721
|
+
// contract (no changes) cannot be upheld. The user can switch to
|
|
722
|
+
// /mode auto or /mode review to proceed.
|
|
723
|
+
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
724
|
+
return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
|
|
725
|
+
}
|
|
666
726
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
667
727
|
}
|
|
668
728
|
if (decision.confirm) {
|
|
@@ -740,13 +800,6 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
740
800
|
});
|
|
741
801
|
const paintMode = (ctx) => {
|
|
742
802
|
deps.modeHolder?.set(mode);
|
|
743
|
-
try {
|
|
744
|
-
if (ctx.hasUI)
|
|
745
|
-
ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
|
|
746
|
-
}
|
|
747
|
-
catch {
|
|
748
|
-
// The chip is chrome; never let it break /mode.
|
|
749
|
-
}
|
|
750
803
|
};
|
|
751
804
|
pi.registerCommand("mode", {
|
|
752
805
|
description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
|
|
@@ -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.
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* `buildLaunch` (no spawn, no fs).
|
|
4
4
|
*
|
|
5
5
|
* `buildStageInvocation` produces the per-stage pi PASSTHROUGH argv exactly as
|
|
6
|
-
* the subagent example assembles it (runSingleAgent L294-330)
|
|
6
|
+
* the subagent example assembles it (runSingleAgent L294-330), with an added
|
|
7
|
+
* honesty preamble delivered as the FIRST `--append-system-prompt` so the
|
|
8
|
+
* anti-fabrication rule appears before the persona body in the child's
|
|
9
|
+
* assembled system prompt:
|
|
7
10
|
* --mode json -p --no-session --model <tier> --tools <csv>
|
|
8
|
-
* --append-system-prompt <file> 'Task: <rendered>'
|
|
11
|
+
* --append-system-prompt <honesty> --append-system-prompt <file> 'Task: <rendered>'
|
|
12
|
+
*
|
|
13
|
+
* Pi concatenates multiple `--append-system-prompt` values with `\n\n` in
|
|
14
|
+
* array order, so the child sees: [pi base] + [honesty] + [persona] +
|
|
15
|
+
* [project_context]. The preamble is placed first so open-weight models
|
|
16
|
+
* encounter the anti-fabrication rule before their role identity.
|
|
9
17
|
*
|
|
10
18
|
* `groundedChildArgv` prepends the grounding pi-args the yagni-code launcher
|
|
11
19
|
* would otherwise add (`-e <self> --provider yagni`) because v1 spawns pi
|
|
@@ -17,6 +25,20 @@
|
|
|
17
25
|
* chain's `step.task.replace(/{previous}/g, …)`.
|
|
18
26
|
*/
|
|
19
27
|
import type { PipelineStage, ReviewLens } from "./types.js";
|
|
28
|
+
/**
|
|
29
|
+
* Anti-fabrication preamble injected as the first `--append-system-prompt` on
|
|
30
|
+
* every child pi process (both /go stages and subagent tool calls). The
|
|
31
|
+
* driver-only `ENGINEERING_PRACTICE_SECTION` never reaches children (it's a
|
|
32
|
+
* launcher flag, and children build their own argv); this preamble ensures
|
|
33
|
+
* the honesty rule is present in every child regardless.
|
|
34
|
+
*
|
|
35
|
+
* Content constraints (same as the enrichment section — the branding scrub's
|
|
36
|
+
* `scrubOutsideProjectContext` replaces whole-word \bpi\b and the
|
|
37
|
+
* `PI_DOCS_BLOCK_RE` regex consumes consecutive `- ` bullet lines that follow
|
|
38
|
+
* a docs header): must not contain the standalone word "pi", must not open
|
|
39
|
+
* with a `- ` bullet line, no emojis.
|
|
40
|
+
*/
|
|
41
|
+
export declare const CHILD_HONESTY_PREAMBLE: string;
|
|
20
42
|
/** Fill {ticket}/{previous}; an absent var renders as empty string. */
|
|
21
43
|
export declare function renderTemplate(tmpl: string, vars: {
|
|
22
44
|
ticket?: string;
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* `buildLaunch` (no spawn, no fs).
|
|
4
4
|
*
|
|
5
5
|
* `buildStageInvocation` produces the per-stage pi PASSTHROUGH argv exactly as
|
|
6
|
-
* the subagent example assembles it (runSingleAgent L294-330)
|
|
6
|
+
* the subagent example assembles it (runSingleAgent L294-330), with an added
|
|
7
|
+
* honesty preamble delivered as the FIRST `--append-system-prompt` so the
|
|
8
|
+
* anti-fabrication rule appears before the persona body in the child's
|
|
9
|
+
* assembled system prompt:
|
|
7
10
|
* --mode json -p --no-session --model <tier> --tools <csv>
|
|
8
|
-
* --append-system-prompt <file> 'Task: <rendered>'
|
|
11
|
+
* --append-system-prompt <honesty> --append-system-prompt <file> 'Task: <rendered>'
|
|
12
|
+
*
|
|
13
|
+
* Pi concatenates multiple `--append-system-prompt` values with `\n\n` in
|
|
14
|
+
* array order, so the child sees: [pi base] + [honesty] + [persona] +
|
|
15
|
+
* [project_context]. The preamble is placed first so open-weight models
|
|
16
|
+
* encounter the anti-fabrication rule before their role identity.
|
|
9
17
|
*
|
|
10
18
|
* `groundedChildArgv` prepends the grounding pi-args the yagni-code launcher
|
|
11
19
|
* would otherwise add (`-e <self> --provider yagni`) because v1 spawns pi
|
|
@@ -16,6 +24,24 @@
|
|
|
16
24
|
* `renderTemplate` does the {ticket}/{previous} substitution, mirroring the
|
|
17
25
|
* chain's `step.task.replace(/{previous}/g, …)`.
|
|
18
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* Anti-fabrication preamble injected as the first `--append-system-prompt` on
|
|
29
|
+
* every child pi process (both /go stages and subagent tool calls). The
|
|
30
|
+
* driver-only `ENGINEERING_PRACTICE_SECTION` never reaches children (it's a
|
|
31
|
+
* launcher flag, and children build their own argv); this preamble ensures
|
|
32
|
+
* the honesty rule is present in every child regardless.
|
|
33
|
+
*
|
|
34
|
+
* Content constraints (same as the enrichment section — the branding scrub's
|
|
35
|
+
* `scrubOutsideProjectContext` replaces whole-word \bpi\b and the
|
|
36
|
+
* `PI_DOCS_BLOCK_RE` regex consumes consecutive `- ` bullet lines that follow
|
|
37
|
+
* a docs header): must not contain the standalone word "pi", must not open
|
|
38
|
+
* with a `- ` bullet line, no emojis.
|
|
39
|
+
*/
|
|
40
|
+
export const CHILD_HONESTY_PREAMBLE = "Never fabricate file paths, file contents, function signatures, enum values, or code. " +
|
|
41
|
+
"If you cannot find something, say so explicitly — an honest \"not found\" is more valuable " +
|
|
42
|
+
"than a plausible-sounding invention. Every file path you cite must be a path you actually " +
|
|
43
|
+
"read with a tool. If you are uncertain whether something exists, say you are uncertain " +
|
|
44
|
+
"rather than presenting a guess as a finding.";
|
|
19
45
|
/** Fill {ticket}/{previous}; an absent var renders as empty string. */
|
|
20
46
|
export function renderTemplate(tmpl, vars) {
|
|
21
47
|
return tmpl
|
|
@@ -41,6 +67,8 @@ export function buildStageInvocation(stage, ctx) {
|
|
|
41
67
|
"--tools",
|
|
42
68
|
stage.tools.join(","),
|
|
43
69
|
"--append-system-prompt",
|
|
70
|
+
CHILD_HONESTY_PREAMBLE,
|
|
71
|
+
"--append-system-prompt",
|
|
44
72
|
ctx.promptFilePath,
|
|
45
73
|
`Task: ${task}`,
|
|
46
74
|
];
|
|
@@ -57,7 +57,7 @@ Budget discipline: you have a hard output budget, and a plan that gets cut off m
|
|
|
57
57
|
Keep it concrete; the worker executes it verbatim.`;
|
|
58
58
|
const WORKER_BODY = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
|
|
59
59
|
|
|
60
|
-
You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it.
|
|
60
|
+
You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
|
|
61
61
|
|
|
62
62
|
You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
|
|
63
63
|
|
|
@@ -107,7 +107,7 @@ Risk taxonomy:
|
|
|
107
107
|
Outcome rules:
|
|
108
108
|
- "deny" is reserved for actions you are VERY CONFIDENT are very dangerous: irreversible destruction, exfiltration of sensitive data, credential probing, production-impacting security weakening. If you are not sure it is that bad, do not deny.
|
|
109
109
|
- "allow" when you are confident the action is safe: read-only, routine dev-loop, or clearly scoped and reversible.
|
|
110
|
-
- "ask" for
|
|
110
|
+
- "ask" for actions where a specific, concrete risk makes you unsure — the action has a plausible path to harm that you cannot rule out by reading the command. Do not ask just because a command "might" do something; ask when you can name the specific thing you are worried about. If you cannot articulate a concrete risk, allow. Prefer allow over ask: routine dev-loop operations, local file writes within a project, and standard git operations (add, commit, fetch) are safe — the agent is already operating in the user's repository with their consent.
|
|
111
111
|
|
|
112
112
|
Rationale rules:
|
|
113
113
|
- For "ask", the rationale MUST be a specific question addressed to the user, naming the concrete effect that made you unsure — e.g. "This pushes 3 commits to the shared main branch — do you want to publish them now?". You may be told why the static policy routed the command to you; your rationale must ADD information beyond that policy text, not restate it.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `withResilience(runStage, policy)` is a composable higher-order function that
|
|
5
5
|
* wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
|
|
6
6
|
* roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
|
|
7
|
-
* event for N ms
|
|
7
|
+
* event for N ms, deferred while a tool is in flight — see the stall note at
|
|
8
|
+
* the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
|
|
8
9
|
* existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
|
|
9
10
|
* retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
|
|
10
11
|
* emitted per attempt.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `withResilience(runStage, policy)` is a composable higher-order function that
|
|
5
5
|
* wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
|
|
6
6
|
* roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
|
|
7
|
-
* event for N ms
|
|
7
|
+
* event for N ms, deferred while a tool is in flight — see the stall note at
|
|
8
|
+
* the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
|
|
8
9
|
* existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
|
|
9
10
|
* retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
|
|
10
11
|
* emitted per attempt.
|
|
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
|
|
|
100
101
|
if (!timeoutController.signal.aborted)
|
|
101
102
|
timeoutController.abort();
|
|
102
103
|
};
|
|
104
|
+
// Tools the child has started but not finished. The idle window measures
|
|
105
|
+
// STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
|
|
106
|
+
// build) emits no NDJSON between its start and end events, and that is
|
|
107
|
+
// progress, not a hang. While a tool is in flight the idle expiry defers
|
|
108
|
+
// and re-arms instead of aborting; the wall-clock timer stays the
|
|
109
|
+
// backstop for a tool that is genuinely hung.
|
|
110
|
+
let inFlightTools = 0;
|
|
103
111
|
let idleTimer;
|
|
104
112
|
const armIdle = () => {
|
|
105
113
|
if (idleTimer)
|
|
106
114
|
clearTimeout(idleTimer);
|
|
107
|
-
idleTimer = setTimeout(
|
|
115
|
+
idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
|
|
108
116
|
idleTimer.unref?.();
|
|
109
117
|
};
|
|
118
|
+
const fireIdle = () => {
|
|
119
|
+
if (inFlightTools > 0) {
|
|
120
|
+
armIdle();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
fireTimeout();
|
|
124
|
+
};
|
|
110
125
|
const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
|
|
111
126
|
wallTimer.unref?.();
|
|
112
127
|
armIdle();
|
|
113
128
|
const originalOnEvent = deps.onEvent;
|
|
114
129
|
const onEvent = (ev) => {
|
|
115
130
|
sawAnyEvent = true;
|
|
131
|
+
if (ev.type === "tool_execution_start")
|
|
132
|
+
inFlightTools += 1;
|
|
133
|
+
else if (ev.type === "tool_execution_end")
|
|
134
|
+
inFlightTools = Math.max(0, inFlightTools - 1);
|
|
116
135
|
armIdle(); // reset the idle window on every live event
|
|
117
136
|
originalOnEvent?.(ev);
|
|
118
137
|
};
|
|
@@ -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
|