@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 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
@@ -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), width, dim("…"));
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), width, dim("…"));
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)), width, dim("…")));
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";
@@ -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): never blocks. Byte-identical to today, so this is additive.
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 fail-open, but stricter
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. auto always
54
- * allows; plan blocks the write/exec set; review marks writes for confirmation
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): never blocks. Byte-identical to today, so this is additive.
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 fail-open, but stricter
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. auto always
35
- * allows; plan blocks the write/exec set; review marks writes for confirmation
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 without prompting (default).",
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([...policy.planBlockTools, ...policy.reviewConfirmTools]);
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: `review mode: ${event.toolName} held (no UI to confirm). Switch to /mode auto to apply.` };
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("YAGNI Code review mode", options);
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
@@ -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.1034.1",
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": "18b902bf72fff144de95d7949d32025875798a1e"
41
+ "yagniSourceSha": "82c8e819af017a1a2c5c93aea5b4f96690f267d3"
42
42
  }