@yagni-app/code-staging 0.2.1-staging.1034.1 → 0.2.1-staging.1038.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.
@@ -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/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.1038.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": "4493e421b21ba86ed37645beecef7c9a74799f4b"
42
42
  }