@yagni-app/code-staging 0.2.1-staging.1034.1 → 0.2.1-staging.1039.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.d.ts +21 -0
- package/dist/cli.js +74 -1
- package/dist/extension/footer.d.ts +9 -1
- package/dist/extension/footer.js +23 -5
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +9 -0
- package/dist/extension/permission.d.ts +7 -4
- package/dist/extension/permission.js +46 -13
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/launch.js +5 -0
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/package.json +2 -2
package/dist/cli.d.ts
CHANGED
|
@@ -12,6 +12,27 @@
|
|
|
12
12
|
* plus a configured pi spawn. Everything that makes this "YAGNI Code" lives in
|
|
13
13
|
* pi-extension-yagni and the YAGNI backend.
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
|
|
17
|
+
* input is padded to match the chat/output area (`outputPad`) and the status
|
|
18
|
+
* bar. Fills the key in only when absent — an explicit user choice (including
|
|
19
|
+
* 0) is never overwritten. Best-effort: a missing/corrupt settings file or a
|
|
20
|
+
* write failure must never block the launch.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
|
|
24
|
+
* input aligns with the chat/output area (`outputPad`) and the status bar.
|
|
25
|
+
*
|
|
26
|
+
* This writes to a user-owned file, so it is deliberately conservative:
|
|
27
|
+
* - fills the key in ONLY when absent — an explicit user choice (including 0)
|
|
28
|
+
* is never overwritten;
|
|
29
|
+
* - backs off on a missing-dir, corrupt, non-object, or symlinked settings
|
|
30
|
+
* file rather than risk clobbering anything;
|
|
31
|
+
* - writes ATOMICALLY (temp file + rename) and preserves the existing file's
|
|
32
|
+
* permissions, so a crash mid-write can never truncate the user's settings.
|
|
33
|
+
* Any failure is swallowed: seeding must never block or break a launch.
|
|
34
|
+
*/
|
|
35
|
+
export declare function seedEditorPadding(piAgentDir: string): void;
|
|
15
36
|
export declare const HELP_TEXT: string;
|
|
16
37
|
/** Parse `use <name> [--base-url <url>]` argv into its parts. */
|
|
17
38
|
export declare function parseUseArgs(args: string[]): {
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* pi-extension-yagni and the YAGNI backend.
|
|
14
14
|
*/
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { mkdirSync, realpathSync } from "node:fs";
|
|
16
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
17
18
|
import { createInterface } from "node:readline/promises";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
20
|
import { PI_CONFIG_NAME } from "./branding.js";
|
|
@@ -27,6 +28,7 @@ import { runDoctor } from "./doctor.js";
|
|
|
27
28
|
import { installProcessCrashHandlers } from "./crashReport.js";
|
|
28
29
|
import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
|
|
29
30
|
import { maybeRefreshAtLaunch } from "./refresh.js";
|
|
31
|
+
import { PAD_X } from "./padding.js";
|
|
30
32
|
import { ensureShadowPiPackage } from "./piPackage.js";
|
|
31
33
|
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
|
|
32
34
|
import { credentialsFromProfile, getActiveProfileName, listProfiles, migrateLegacyCredentials, persistProfileTokenRotation, profilePath, readActiveProfile, useProfile, } from "./profiles.js";
|
|
@@ -43,6 +45,72 @@ async function confirmOnTty(question) {
|
|
|
43
45
|
rl.close();
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
|
|
50
|
+
* input is padded to match the chat/output area (`outputPad`) and the status
|
|
51
|
+
* bar. Fills the key in only when absent — an explicit user choice (including
|
|
52
|
+
* 0) is never overwritten. Best-effort: a missing/corrupt settings file or a
|
|
53
|
+
* write failure must never block the launch.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
|
|
57
|
+
* input aligns with the chat/output area (`outputPad`) and the status bar.
|
|
58
|
+
*
|
|
59
|
+
* This writes to a user-owned file, so it is deliberately conservative:
|
|
60
|
+
* - fills the key in ONLY when absent — an explicit user choice (including 0)
|
|
61
|
+
* is never overwritten;
|
|
62
|
+
* - backs off on a missing-dir, corrupt, non-object, or symlinked settings
|
|
63
|
+
* file rather than risk clobbering anything;
|
|
64
|
+
* - writes ATOMICALLY (temp file + rename) and preserves the existing file's
|
|
65
|
+
* permissions, so a crash mid-write can never truncate the user's settings.
|
|
66
|
+
* Any failure is swallowed: seeding must never block or break a launch.
|
|
67
|
+
*/
|
|
68
|
+
export function seedEditorPadding(piAgentDir) {
|
|
69
|
+
try {
|
|
70
|
+
const settingsPath = join(piAgentDir, "settings.json");
|
|
71
|
+
let settings = {};
|
|
72
|
+
let existingMode;
|
|
73
|
+
if (existsSync(settingsPath)) {
|
|
74
|
+
// Refuse to follow a symlink: we must only ever write a regular, real
|
|
75
|
+
// settings file the user (or pi) owns.
|
|
76
|
+
if (lstatSync(settingsPath).isSymbolicLink())
|
|
77
|
+
return;
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return; // corrupt file: back off rather than clobber the user's settings
|
|
84
|
+
}
|
|
85
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
86
|
+
return; // non-object settings: leave it alone
|
|
87
|
+
}
|
|
88
|
+
settings = parsed;
|
|
89
|
+
existingMode = lstatSync(settingsPath).mode & 0o777;
|
|
90
|
+
}
|
|
91
|
+
if (settings.editorPaddingX !== undefined)
|
|
92
|
+
return; // user already chose
|
|
93
|
+
settings.editorPaddingX = PAD_X;
|
|
94
|
+
// Atomic write: serialize to a temp file in the same directory, then
|
|
95
|
+
// rename over the target. A crash leaves either the old file or the temp
|
|
96
|
+
// file, never a half-written settings.json.
|
|
97
|
+
const tmpPath = join(piAgentDir, `.settings.json.yagni-${process.pid}-${Date.now()}.tmp`);
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
100
|
+
renameSync(tmpPath, settingsPath);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
rmSync(tmpPath, { force: true }); // no-op once the rename succeeded
|
|
104
|
+
}
|
|
105
|
+
// Restore the original permissions if the file already had them (rename
|
|
106
|
+
// replaces the inode, which would otherwise reset to 0600).
|
|
107
|
+
if (existingMode !== undefined)
|
|
108
|
+
chmodSync(settingsPath, existingMode);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// never block launch on a settings-seed failure
|
|
112
|
+
}
|
|
113
|
+
}
|
|
46
114
|
async function runDefault(passthroughArgs) {
|
|
47
115
|
// Cache-backed update nudge (never a network wait), then a background cache
|
|
48
116
|
// refresh that completes while the session runs. Both fail soft.
|
|
@@ -72,6 +140,11 @@ async function runDefault(passthroughArgs) {
|
|
|
72
140
|
// instead of ~/.pi/agent, and prod state never bleeds into staging.
|
|
73
141
|
const piAgentDir = agentDir(profile.name);
|
|
74
142
|
mkdirSync(piAgentDir, { recursive: true, mode: 0o700 });
|
|
143
|
+
// Seed the editor's horizontal padding so the prompt input aligns with the
|
|
144
|
+
// chat/output area and the status bar. Only fills in when the user has not
|
|
145
|
+
// set it themselves — an explicit choice is never clobbered. Best-effort: a
|
|
146
|
+
// corrupt or unreadable settings.json must never block the launch.
|
|
147
|
+
seedEditorPadding(piAgentDir);
|
|
75
148
|
// Generate the shadow pi package so the terminal title/process name read
|
|
76
149
|
// "YAGNI Code" instead of "pi"/"π". Best-effort: if it can't be built we
|
|
77
150
|
// still launch (un-rebranded but hermetic), never blocking the agent.
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
42
42
|
export declare const BRANCH_MAX_WIDTH = 60;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
|
|
45
|
+
* aligns with the editor input and the chat/output area on one shared column.
|
|
46
|
+
* The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
|
|
47
|
+
* read pi settings, so the value crosses over env. Falls back to the output
|
|
48
|
+
* area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveFooterPadX(raw?: string | undefined): number;
|
|
43
51
|
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
44
52
|
export declare function formatTokens(count: number): string;
|
|
45
53
|
/** Shorten cwd relative to home, like pi's built-in footer. */
|
|
@@ -85,7 +93,7 @@ export declare function renderFooterLines(input: {
|
|
|
85
93
|
usage: UsageTotals;
|
|
86
94
|
contextPercent: number | null;
|
|
87
95
|
statuses: string[];
|
|
88
|
-
}, theme: Pick<Theme, "fg">, width: number): string[];
|
|
96
|
+
}, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
|
|
89
97
|
/**
|
|
90
98
|
* Create a footer factory that captures the session `ctx` (for session data)
|
|
91
99
|
* and returns the component `setFooter` expects. Called from the
|
package/dist/extension/footer.js
CHANGED
|
@@ -46,6 +46,19 @@ export const BRANCH_MAX_WIDTH = 60;
|
|
|
46
46
|
const WORKTREE_MAX_WIDTH = 30;
|
|
47
47
|
/** Section separator: single space + middle dot + single space. */
|
|
48
48
|
const SEP = " · ";
|
|
49
|
+
/** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
|
|
50
|
+
const DEFAULT_PAD_X = 1;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
|
|
53
|
+
* aligns with the editor input and the chat/output area on one shared column.
|
|
54
|
+
* The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
|
|
55
|
+
* read pi settings, so the value crosses over env. Falls back to the output
|
|
56
|
+
* area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveFooterPadX(raw = process.env.YAGNI_PAD_X) {
|
|
59
|
+
const n = raw === undefined ? NaN : Number.parseInt(raw, 10);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 && n <= 3 ? n : DEFAULT_PAD_X;
|
|
61
|
+
}
|
|
49
62
|
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
50
63
|
export function formatTokens(count) {
|
|
51
64
|
if (count < 1000)
|
|
@@ -191,9 +204,14 @@ function contextColor(percent) {
|
|
|
191
204
|
return "dim";
|
|
192
205
|
}
|
|
193
206
|
/** Pure line-builder, exported for tests. All data injected; colors via theme. */
|
|
194
|
-
export function renderFooterLines(input, theme, width) {
|
|
207
|
+
export function renderFooterLines(input, theme, width, padX = 0) {
|
|
195
208
|
const dim = (s) => theme.fg("dim", s);
|
|
196
209
|
const sep = dim(SEP);
|
|
210
|
+
// Reserve the left pad so the status bar's text starts on the same column as
|
|
211
|
+
// the (padded) editor input and the chat/output area, instead of hugging the
|
|
212
|
+
// terminal edge. Truncation runs against the reduced content width.
|
|
213
|
+
const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
|
|
214
|
+
const contentWidth = Math.max(1, width - pad.length);
|
|
197
215
|
// Line 1: folder · [worktree] · branch
|
|
198
216
|
const line1Parts = [theme.fg("accent", input.git.folder)];
|
|
199
217
|
if (input.git.inRepo) {
|
|
@@ -202,7 +220,7 @@ export function renderFooterLines(input, theme, width) {
|
|
|
202
220
|
if (input.git.branch)
|
|
203
221
|
line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
|
|
204
222
|
}
|
|
205
|
-
const line1 = truncateToWidth(line1Parts.join(sep),
|
|
223
|
+
const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
|
|
206
224
|
// Line 2: model · ↑in ↓out $cost · ctx%
|
|
207
225
|
const statParts = [];
|
|
208
226
|
if (input.usage.input)
|
|
@@ -217,12 +235,12 @@ export function renderFooterLines(input, theme, width) {
|
|
|
217
235
|
if (stats)
|
|
218
236
|
line2Parts.push(dim(stats));
|
|
219
237
|
line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
|
|
220
|
-
const line2 = truncateToWidth(line2Parts.join(sep),
|
|
238
|
+
const line2 = pad + truncateToWidth(line2Parts.join(sep), contentWidth, dim("…"));
|
|
221
239
|
// Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
|
|
222
240
|
const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
|
|
223
241
|
const lines = [line1, line2];
|
|
224
242
|
if (statuses.length > 0) {
|
|
225
|
-
lines.push(truncateToWidth(dim(statuses.join(SEP)),
|
|
243
|
+
lines.push(pad + truncateToWidth(dim(statuses.join(SEP)), contentWidth, dim("…")));
|
|
226
244
|
}
|
|
227
245
|
return lines;
|
|
228
246
|
}
|
|
@@ -258,7 +276,7 @@ export function createYagniFooterFactory(ctx) {
|
|
|
258
276
|
usage: collectUsage(ctx.sessionManager),
|
|
259
277
|
contextPercent: ctx.getContextUsage()?.percent ?? null,
|
|
260
278
|
statuses,
|
|
261
|
-
}, theme, width);
|
|
279
|
+
}, theme, width, resolveFooterPadX());
|
|
262
280
|
},
|
|
263
281
|
invalidate() {
|
|
264
282
|
gitCache = undefined;
|
|
@@ -97,6 +97,7 @@ export declare function parseSpendResponse(data: unknown): SpendResponse | null;
|
|
|
97
97
|
export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
|
|
98
98
|
export default function (pi: ExtensionAPI): Promise<void>;
|
|
99
99
|
export { makeAskYagniTool } from "./askYagniTool.js";
|
|
100
|
+
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
100
101
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
101
102
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
102
103
|
export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
|
package/dist/extension/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
5
5
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
6
6
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
7
|
+
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
7
8
|
import { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
8
9
|
import { registerCmuxBridge } from "./cmux/index.js";
|
|
9
10
|
import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
@@ -126,6 +127,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
126
127
|
pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
|
|
127
128
|
const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
|
|
128
129
|
pi.registerTool(makeAskYagniTool(toolOpts));
|
|
130
|
+
// Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
|
|
131
|
+
// workspace tracker, attributed to the developer via per-user credentials.
|
|
132
|
+
if (!evalMode) {
|
|
133
|
+
pi.registerTool(makeFileTicketTool(toolOpts));
|
|
134
|
+
pi.registerTool(makeUpdateTicketStatusTool(toolOpts));
|
|
135
|
+
}
|
|
129
136
|
// The peak-tier escalation for Advanced sessions (YAG-380). Registered
|
|
130
137
|
// UNCONDITIONALLY and gated at execute time on the live session model: pi's
|
|
131
138
|
// picker can switch the model after activation, so a registration-time tier
|
|
@@ -225,6 +232,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
225
232
|
...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
|
|
226
233
|
...mcpMutatingTools,
|
|
227
234
|
],
|
|
235
|
+
alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
|
|
228
236
|
},
|
|
229
237
|
}
|
|
230
238
|
: {}),
|
|
@@ -556,6 +564,7 @@ export default async function (pi) {
|
|
|
556
564
|
registerCmuxBridge(pi);
|
|
557
565
|
}
|
|
558
566
|
export { makeAskYagniTool } from "./askYagniTool.js";
|
|
567
|
+
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
559
568
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
560
569
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
561
570
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* YAGNI Code registers no tool_call handler today, so the interactive session has
|
|
5
5
|
* no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
|
|
6
6
|
* plus a `/mode` command:
|
|
7
|
-
* - auto (default):
|
|
7
|
+
* - auto (default): ordinary coding tools run directly; external tracker
|
|
8
|
+
* changes still require fresh human confirmation.
|
|
8
9
|
* - plan : blocks write/edit/bash so the agent can explore + propose without
|
|
9
10
|
* touching the tree.
|
|
10
11
|
* - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* adds a session-scoped bless rule AND drafts a decision capture.
|
|
15
16
|
*
|
|
16
17
|
* `decideGate` is PURE; the live wiring holds the mode in a small closure (no
|
|
17
|
-
* module-global state). The default auto mode remains
|
|
18
|
+
* module-global state). The default auto mode remains direct for coding tools, but stricter
|
|
18
19
|
* modes fail closed for side-effect tools if the gate itself errors. Bless rules
|
|
19
20
|
* are session-scoped, path-prefix-bound, never persisted, and never consulted in
|
|
20
21
|
* plan mode (plan blocks outright before isBlessed is reached).
|
|
@@ -34,6 +35,8 @@ export interface PermissionPolicy {
|
|
|
34
35
|
planBlockTools: string[];
|
|
35
36
|
/** Tools that prompt for confirmation in review mode. */
|
|
36
37
|
reviewConfirmTools: string[];
|
|
38
|
+
/** Consequential external writes that require fresh consent in every mode. */
|
|
39
|
+
alwaysConfirmTools?: string[];
|
|
37
40
|
/**
|
|
38
41
|
* Optional: a recorded decision already blesses this action, so it auto-runs in
|
|
39
42
|
* review mode instead of prompting. The hook for tying the gate to captured
|
|
@@ -50,8 +53,8 @@ export interface GateDecision {
|
|
|
50
53
|
confirm?: boolean;
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
|
-
* Pure permission decision for one tool call under a mode + policy.
|
|
54
|
-
*
|
|
56
|
+
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
57
|
+
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
55
58
|
* unless a recorded decision blesses them.
|
|
56
59
|
*/
|
|
57
60
|
export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* YAGNI Code registers no tool_call handler today, so the interactive session has
|
|
5
5
|
* no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
|
|
6
6
|
* plus a `/mode` command:
|
|
7
|
-
* - auto (default):
|
|
7
|
+
* - auto (default): ordinary coding tools run directly; external tracker
|
|
8
|
+
* changes still require fresh human confirmation.
|
|
8
9
|
* - plan : blocks write/edit/bash so the agent can explore + propose without
|
|
9
10
|
* touching the tree.
|
|
10
11
|
* - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* adds a session-scoped bless rule AND drafts a decision capture.
|
|
15
16
|
*
|
|
16
17
|
* `decideGate` is PURE; the live wiring holds the mode in a small closure (no
|
|
17
|
-
* module-global state). The default auto mode remains
|
|
18
|
+
* module-global state). The default auto mode remains direct for coding tools, but stricter
|
|
18
19
|
* modes fail closed for side-effect tools if the gate itself errors. Bless rules
|
|
19
20
|
* are session-scoped, path-prefix-bound, never persisted, and never consulted in
|
|
20
21
|
* plan mode (plan blocks outright before isBlessed is reached).
|
|
@@ -27,17 +28,16 @@
|
|
|
27
28
|
*/
|
|
28
29
|
import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
|
|
29
30
|
export const DEFAULT_PERMISSION_POLICY = {
|
|
30
|
-
planBlockTools: ["write", "edit", "bash"],
|
|
31
|
-
reviewConfirmTools: ["write", "edit", "bash"],
|
|
31
|
+
planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
|
|
32
|
+
reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
|
|
33
|
+
alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
|
|
32
34
|
};
|
|
33
35
|
/**
|
|
34
|
-
* Pure permission decision for one tool call under a mode + policy.
|
|
35
|
-
*
|
|
36
|
+
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
37
|
+
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
36
38
|
* unless a recorded decision blesses them.
|
|
37
39
|
*/
|
|
38
40
|
export function decideGate(toolName, params, mode, policy) {
|
|
39
|
-
if (mode === "auto")
|
|
40
|
-
return { block: false };
|
|
41
41
|
if (mode === "plan") {
|
|
42
42
|
if (policy.planBlockTools.includes(toolName)) {
|
|
43
43
|
return {
|
|
@@ -47,6 +47,11 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
47
47
|
}
|
|
48
48
|
return { block: false };
|
|
49
49
|
}
|
|
50
|
+
if (policy.alwaysConfirmTools?.includes(toolName)) {
|
|
51
|
+
return { block: false, confirm: true };
|
|
52
|
+
}
|
|
53
|
+
if (mode === "auto")
|
|
54
|
+
return { block: false };
|
|
50
55
|
// review
|
|
51
56
|
if (policy.reviewConfirmTools.includes(toolName)) {
|
|
52
57
|
if (policy.isBlessed?.(toolName, params))
|
|
@@ -96,7 +101,7 @@ const MODE_STATUS = {
|
|
|
96
101
|
review: "✓ review",
|
|
97
102
|
};
|
|
98
103
|
const MODE_COPY = {
|
|
99
|
-
auto: "auto: changes apply
|
|
104
|
+
auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
|
|
100
105
|
plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
|
|
101
106
|
review: "review: you confirm each write, edit, or bash command before it applies.",
|
|
102
107
|
};
|
|
@@ -104,7 +109,32 @@ function isMode(value) {
|
|
|
104
109
|
return value === "auto" || value === "plan" || value === "review";
|
|
105
110
|
}
|
|
106
111
|
function sideEffectTools(policy) {
|
|
107
|
-
return new Set([
|
|
112
|
+
return new Set([
|
|
113
|
+
...policy.planBlockTools,
|
|
114
|
+
...policy.reviewConfirmTools,
|
|
115
|
+
...(policy.alwaysConfirmTools ?? []),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
function boundedPromptValue(value, fallback) {
|
|
119
|
+
if (typeof value !== "string")
|
|
120
|
+
return fallback;
|
|
121
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
122
|
+
if (normalized.length === 0)
|
|
123
|
+
return fallback;
|
|
124
|
+
return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}…`;
|
|
125
|
+
}
|
|
126
|
+
function externalTrackerPrompt(toolName, input) {
|
|
127
|
+
if (toolName === "file_ticket") {
|
|
128
|
+
const title = boundedPromptValue(input.title, "Untitled ticket");
|
|
129
|
+
const target = boundedPromptValue(input.target_key, "default project/team");
|
|
130
|
+
return `File “${title}” in ${target}?`;
|
|
131
|
+
}
|
|
132
|
+
if (toolName === "update_ticket_status") {
|
|
133
|
+
const ref = boundedPromptValue(input.ref, "ticket");
|
|
134
|
+
const status = boundedPromptValue(input.status, "requested status");
|
|
135
|
+
return `Move ${ref} to ${status}?`;
|
|
136
|
+
}
|
|
137
|
+
return "Confirm external tracker change";
|
|
108
138
|
}
|
|
109
139
|
/**
|
|
110
140
|
* Wire the tool_call gate + the /mode command onto a shared mode holder. Default
|
|
@@ -121,6 +151,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
121
151
|
const effectivePolicy = {
|
|
122
152
|
planBlockTools: basePolicy.planBlockTools,
|
|
123
153
|
reviewConfirmTools: basePolicy.reviewConfirmTools,
|
|
154
|
+
alwaysConfirmTools: basePolicy.alwaysConfirmTools,
|
|
124
155
|
isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
|
|
125
156
|
};
|
|
126
157
|
const sideEffects = sideEffectTools(effectivePolicy);
|
|
@@ -136,7 +167,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
136
167
|
// cannot get consent for is held rather than silently auto-applied (this
|
|
137
168
|
// mirrors plan mode, which blocks regardless of UI).
|
|
138
169
|
if (!ctx?.hasUI) {
|
|
139
|
-
return { block: true, reason:
|
|
170
|
+
return { block: true, reason: `${event.toolName} held (no UI to confirm this action).` };
|
|
140
171
|
}
|
|
141
172
|
// Lazily bind the bless store to this session's cwd.
|
|
142
173
|
if (!blessStore)
|
|
@@ -144,14 +175,16 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
144
175
|
// Three-way prompt (pi's confirm is boolean-only, so use select): Yes,
|
|
145
176
|
// Yes-and-remember (only when a path-prefix bless is meaningful), or No.
|
|
146
177
|
const dir = blessStore.describeDir(input);
|
|
147
|
-
const blessable = dir !== null;
|
|
178
|
+
const blessable = dir !== null && !effectivePolicy.alwaysConfirmTools?.includes(event.toolName);
|
|
148
179
|
const yes = "Yes";
|
|
149
180
|
const no = "No";
|
|
150
181
|
const remember = blessable
|
|
151
182
|
? `Yes, and don't ask again for ${event.toolName} in ${dir}`
|
|
152
183
|
: undefined;
|
|
153
184
|
const options = blessable ? [yes, remember, no] : [yes, no];
|
|
154
|
-
const choice = await ctx.ui.select(
|
|
185
|
+
const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
|
|
186
|
+
? externalTrackerPrompt(event.toolName, input)
|
|
187
|
+
: "YAGNI Code review mode", options);
|
|
155
188
|
if (choice === yes)
|
|
156
189
|
return {};
|
|
157
190
|
if (blessable && choice === remember) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
/**
|
|
4
|
+
* Ticket write-back tools (spec 2026-08-09): file a ticket, move a ticket.
|
|
5
|
+
*
|
|
6
|
+
* Both are EXPLICIT user-intent actions — the guidelines pin the agent to
|
|
7
|
+
* calling them only when the developer asked. Writes execute server-side as
|
|
8
|
+
* the developer's own tracker account (per-user write identity); when no
|
|
9
|
+
* personal connection exists the backend answers 412 `connect_required` and
|
|
10
|
+
* the tool renders the one-time connect prompt instead of failing opaquely.
|
|
11
|
+
*/
|
|
12
|
+
export interface MakeTicketToolOptions {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
getToken: () => string | undefined;
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
declare const fileTicketParams: Type.TObject<{
|
|
18
|
+
title: Type.TString;
|
|
19
|
+
description: Type.TOptional<Type.TString>;
|
|
20
|
+
tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
|
|
21
|
+
target_key: Type.TOptional<Type.TString>;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function makeFileTicketTool(opts: MakeTicketToolOptions): ToolDefinition<typeof fileTicketParams, {
|
|
24
|
+
identifier?: string;
|
|
25
|
+
url?: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
declare const updateTicketStatusParams: Type.TObject<{
|
|
28
|
+
ref: Type.TString;
|
|
29
|
+
status: Type.TString;
|
|
30
|
+
tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function makeUpdateTicketStatusTool(opts: MakeTicketToolOptions): ToolDefinition<typeof updateTicketStatusParams, {
|
|
33
|
+
identifier?: string;
|
|
34
|
+
state?: string;
|
|
35
|
+
}>;
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=ticketTools.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch, } from "./resilientFetch.js";
|
|
3
|
+
/** Render backend problem responses (412/404/422) as agent-relayable text. */
|
|
4
|
+
async function renderProblem(toolName, res) {
|
|
5
|
+
let body = null;
|
|
6
|
+
try {
|
|
7
|
+
body = (await res.clone().json());
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
body = null;
|
|
11
|
+
}
|
|
12
|
+
if (res.status === 412 && body?.error === "connect_required") {
|
|
13
|
+
return [
|
|
14
|
+
`A personal ${body.service ?? "tracker"} connection is needed to write as you.`,
|
|
15
|
+
`Connect here (one time): ${body.connect_url ?? "(ask your admin for the connect link)"}`,
|
|
16
|
+
"Then ask me again and I'll retry.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
if ((res.status === 404 || res.status === 422) && body?.error) {
|
|
20
|
+
const options = body.options?.length
|
|
21
|
+
? `\nAvailable options: ${body.options.join(", ")}`
|
|
22
|
+
: "";
|
|
23
|
+
return `${body.message ?? body.error}${options}`;
|
|
24
|
+
}
|
|
25
|
+
throw new Error(await friendlyFetchError(toolName, res));
|
|
26
|
+
}
|
|
27
|
+
const fileTicketParams = Type.Object({
|
|
28
|
+
title: Type.String(),
|
|
29
|
+
description: Type.Optional(Type.String()),
|
|
30
|
+
tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
|
|
31
|
+
target_key: Type.Optional(Type.String()),
|
|
32
|
+
});
|
|
33
|
+
export function makeFileTicketTool(opts) {
|
|
34
|
+
return {
|
|
35
|
+
name: "file_ticket",
|
|
36
|
+
label: "File Ticket",
|
|
37
|
+
description: "File a ticket in the workspace's tracker (Jira or Linear), attributed to the developer's " +
|
|
38
|
+
"own account. ONLY call when the user explicitly asks to file/create a ticket — never " +
|
|
39
|
+
"speculatively, never as a side effect of other work. Pass target_key (Jira project key or " +
|
|
40
|
+
"Linear team key) when the workspace has more than one.",
|
|
41
|
+
promptSnippet: "file_ticket: file a ticket in the workspace tracker as the developer (explicit request only).",
|
|
42
|
+
promptGuidelines: [
|
|
43
|
+
"Call file_ticket ONLY when the user explicitly asks to file, create, or capture a ticket.",
|
|
44
|
+
"If the tool reports a connect prompt or asks for a target/tracker, relay it verbatim and wait for the user.",
|
|
45
|
+
],
|
|
46
|
+
parameters: fileTicketParams,
|
|
47
|
+
async execute(toolCallId, params, signal) {
|
|
48
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
|
|
55
|
+
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const text = await renderProblem("file_ticket", res);
|
|
58
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
59
|
+
}
|
|
60
|
+
const data = (await res.json());
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: `Filed ${data.identifier}${data.url ? `: ${data.url}` : ""}`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
details: { identifier: data.identifier, url: data.url },
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const updateTicketStatusParams = Type.Object({
|
|
74
|
+
ref: Type.String(),
|
|
75
|
+
status: Type.String(),
|
|
76
|
+
tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
|
|
77
|
+
});
|
|
78
|
+
export function makeUpdateTicketStatusTool(opts) {
|
|
79
|
+
return {
|
|
80
|
+
name: "update_ticket_status",
|
|
81
|
+
label: "Update Ticket Status",
|
|
82
|
+
description: "Move a ticket to a new status by name (e.g. mark YAG-123 In Progress), attributed to the " +
|
|
83
|
+
"developer's own tracker account. ONLY call on the user's explicit request — never as an " +
|
|
84
|
+
"automatic side effect of starting or finishing work.",
|
|
85
|
+
promptSnippet: "update_ticket_status: move a tracker ticket to a named status as the developer (explicit request only).",
|
|
86
|
+
promptGuidelines: [
|
|
87
|
+
"Call update_ticket_status ONLY when the user explicitly asks to move/mark a ticket's status.",
|
|
88
|
+
"If the requested status is not available, relay the offered options and let the user pick.",
|
|
89
|
+
],
|
|
90
|
+
parameters: updateTicketStatusParams,
|
|
91
|
+
async execute(toolCallId, params, signal) {
|
|
92
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets/transition`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
|
|
99
|
+
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await renderProblem("update_ticket_status", res);
|
|
102
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
103
|
+
}
|
|
104
|
+
const data = (await res.json());
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: `${data.identifier} → ${data.state ?? params.status}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
details: { identifier: data.identifier, state: data.state },
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=ticketTools.js.map
|
package/dist/launch.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { agentDirEnvVar } from "./branding.js";
|
|
10
|
+
import { PAD_X_ENV, resolvePadX } from "./padding.js";
|
|
10
11
|
/**
|
|
11
12
|
* How close to expiry the token can be before launch warns. A coding session
|
|
12
13
|
* easily outlives a token, so warn early enough that re-logging in before a long
|
|
@@ -95,6 +96,10 @@ export function buildLaunch(creds, passthroughArgs, opts) {
|
|
|
95
96
|
// Match the e2b harness defaults: skip pi's update check + telemetry.
|
|
96
97
|
PI_SKIP_VERSION_CHECK: "1",
|
|
97
98
|
PI_TELEMETRY: "0",
|
|
99
|
+
// Forward the chosen horizontal pad so the extension's footer aligns with
|
|
100
|
+
// the editor (which the launcher pads by seeding editorPaddingX). The
|
|
101
|
+
// footer can't read pi's settings, so the value crosses over env.
|
|
102
|
+
[PAD_X_ENV]: String(resolvePadX()),
|
|
98
103
|
};
|
|
99
104
|
// Always load our extension. Default the provider to `yagni` unless the user
|
|
100
105
|
// explicitly chose one (so power users can still point pi elsewhere).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the CLI's horizontal padding, shared by the
|
|
3
|
+
* launcher (which seeds the editor setting) and — via the `YAGNI_PAD_X` env the
|
|
4
|
+
* launcher forwards — pi-extension-yagni's footer.
|
|
5
|
+
*
|
|
6
|
+
* The value must match pi's `outputPad` (the chat/output area's left+right
|
|
7
|
+
* padding, which defaults to 1) so the input editor, the output stream, and
|
|
8
|
+
* the status bar all align on the same column. Keeping it in one module makes
|
|
9
|
+
* the coherence structural rather than eyeballed: change this one number and
|
|
10
|
+
* the editor, footer, and seeded default move together.
|
|
11
|
+
*
|
|
12
|
+
* Separators (the ─ rules above/below the input) intentionally stay full-bleed
|
|
13
|
+
* and are NOT padded — matching Claude Code's frame.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PAD_X = 1;
|
|
16
|
+
/** Left (and right) padding string applied to the editor and footer. */
|
|
17
|
+
export declare const PAD_STR: string;
|
|
18
|
+
/** Env var the launcher sets so the extension's footer can match the editor pad. */
|
|
19
|
+
export declare const PAD_X_ENV = "YAGNI_PAD_X";
|
|
20
|
+
/** Parse a pad width from the env override, falling back to {@link PAD_X}. */
|
|
21
|
+
export declare function resolvePadX(raw?: string | undefined): number;
|
|
22
|
+
//# sourceMappingURL=padding.d.ts.map
|
package/dist/padding.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the CLI's horizontal padding, shared by the
|
|
3
|
+
* launcher (which seeds the editor setting) and — via the `YAGNI_PAD_X` env the
|
|
4
|
+
* launcher forwards — pi-extension-yagni's footer.
|
|
5
|
+
*
|
|
6
|
+
* The value must match pi's `outputPad` (the chat/output area's left+right
|
|
7
|
+
* padding, which defaults to 1) so the input editor, the output stream, and
|
|
8
|
+
* the status bar all align on the same column. Keeping it in one module makes
|
|
9
|
+
* the coherence structural rather than eyeballed: change this one number and
|
|
10
|
+
* the editor, footer, and seeded default move together.
|
|
11
|
+
*
|
|
12
|
+
* Separators (the ─ rules above/below the input) intentionally stay full-bleed
|
|
13
|
+
* and are NOT padded — matching Claude Code's frame.
|
|
14
|
+
*/
|
|
15
|
+
export const PAD_X = 1;
|
|
16
|
+
/** Left (and right) padding string applied to the editor and footer. */
|
|
17
|
+
export const PAD_STR = " ".repeat(PAD_X);
|
|
18
|
+
/** Env var the launcher sets so the extension's footer can match the editor pad. */
|
|
19
|
+
export const PAD_X_ENV = "YAGNI_PAD_X";
|
|
20
|
+
/** Parse a pad width from the env override, falling back to {@link PAD_X}. */
|
|
21
|
+
export function resolvePadX(raw = process.env[PAD_X_ENV]) {
|
|
22
|
+
const n = raw === undefined ? NaN : Number.parseInt(raw, 10);
|
|
23
|
+
return Number.isFinite(n) && n >= 0 && n <= 3 ? n : PAD_X;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=padding.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.2.1-staging.
|
|
3
|
+
"version": "0.2.1-staging.1039.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.83.0",
|
|
39
39
|
"typebox": "^1.1.38"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "82c8e819af017a1a2c5c93aea5b4f96690f267d3"
|
|
42
42
|
}
|