@mgiles/perk 1.0.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/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The warm `/address` door (the review loop). Classify-then-act: a spawned read-only child
|
|
2
|
+
// (the borrowed `pi-subagents` engine running perk's `perk.review-classifier` agent) fetches +
|
|
3
|
+
// classifies the PR feedback in ISOLATION, so the verbose GitHub JSON never enters this session;
|
|
4
|
+
// the PARENT applies fixes (judgment + edits stay here) and resolves the threads through this
|
|
5
|
+
// deterministic batched op.
|
|
6
|
+
//
|
|
7
|
+
// `resolve_review_threads` is the mechanical half: it DELEGATES the GitHub mutation to the Python
|
|
8
|
+
// cold door (`perk pr resolve-threads` — mutations canonical in Python) via the shared cold-door
|
|
9
|
+
// client (`runColdDoor` — the batch rides the run-scratch stdin channel), then
|
|
10
|
+
// appends `last_review_batch` to `perk:workflow-state`. Never throws (soft `details.ok`, mirrors
|
|
11
|
+
// submitPr).
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
15
|
+
import { type PlanRef, readPlanRef } from "../substrate/cache.ts";
|
|
16
|
+
import {
|
|
17
|
+
booleanField,
|
|
18
|
+
type ColdJson,
|
|
19
|
+
nullableStringField,
|
|
20
|
+
runColdDoor,
|
|
21
|
+
stringField,
|
|
22
|
+
} from "../substrate/coldDoor.ts";
|
|
23
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
24
|
+
import { render } from "../substrate/prompts.ts";
|
|
25
|
+
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
26
|
+
import {
|
|
27
|
+
arrayParam,
|
|
28
|
+
numberParam,
|
|
29
|
+
objectParam,
|
|
30
|
+
paramsOf,
|
|
31
|
+
stringParam,
|
|
32
|
+
type ToolParams,
|
|
33
|
+
} from "../substrate/toolParams.ts";
|
|
34
|
+
import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
35
|
+
import { report } from "../surfaces/report.ts";
|
|
36
|
+
|
|
37
|
+
interface ThreadInput {
|
|
38
|
+
thread_id: string;
|
|
39
|
+
comment?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ResolveCounts {
|
|
43
|
+
actionable?: number;
|
|
44
|
+
informational?: number;
|
|
45
|
+
praise?: number;
|
|
46
|
+
question?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ResolveParams {
|
|
50
|
+
threads: ThreadInput[];
|
|
51
|
+
pr?: number;
|
|
52
|
+
counts?: ResolveCounts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The four known `counts` keys (recorded into workflow-state — strict-decoded). */
|
|
56
|
+
const COUNT_KEYS = ["actionable", "informational", "praise", "question"] as const;
|
|
57
|
+
|
|
58
|
+
/** Decode the optional `counts` object; null = present-but-mistyped (a key or the object). */
|
|
59
|
+
function decodeCounts(p: ToolParams): ResolveCounts | undefined | null {
|
|
60
|
+
const raw = objectParam(p, "counts");
|
|
61
|
+
if (raw === undefined) return undefined;
|
|
62
|
+
if (raw === null) return null;
|
|
63
|
+
const counts: ResolveCounts = {};
|
|
64
|
+
for (const key of COUNT_KEYS) {
|
|
65
|
+
const value = numberParam(raw, key);
|
|
66
|
+
if (value === null) return null;
|
|
67
|
+
if (value !== undefined) counts[key] = value;
|
|
68
|
+
}
|
|
69
|
+
return counts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decode unknown tool-call params into `ResolveParams` (the tool-boundary seam).
|
|
74
|
+
* `threads` absent or non-array decodes to `[]` (so the existing empty-batch `bad_input` arm
|
|
75
|
+
* fires); any malformed ROW → null — whole-batch refusal, since resolving a guessed subset of
|
|
76
|
+
* threads is a durable GitHub mutation. `pr`/`counts` mistyped → null (recorded state).
|
|
77
|
+
*/
|
|
78
|
+
export function decodeResolveParams(params: unknown): ResolveParams | null {
|
|
79
|
+
const p = paramsOf(params);
|
|
80
|
+
if (p === null) return null;
|
|
81
|
+
const rawThreads = arrayParam(p, "threads");
|
|
82
|
+
const threads: ThreadInput[] = [];
|
|
83
|
+
if (Array.isArray(rawThreads)) {
|
|
84
|
+
for (const item of rawThreads) {
|
|
85
|
+
const row = paramsOf(item);
|
|
86
|
+
if (row === null) return null;
|
|
87
|
+
const threadId = stringParam(row, "thread_id");
|
|
88
|
+
const comment = stringParam(row, "comment");
|
|
89
|
+
if (typeof threadId !== "string" || comment === null) return null;
|
|
90
|
+
threads.push({ thread_id: threadId, comment });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const pr = numberParam(p, "pr");
|
|
94
|
+
if (pr === null) return null;
|
|
95
|
+
const counts = decodeCounts(p);
|
|
96
|
+
if (counts === null) return null;
|
|
97
|
+
return { threads, pr, counts };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One per-thread outcome row from the cold door's batch result. */
|
|
101
|
+
export interface ThreadResultRow {
|
|
102
|
+
thread_id: string;
|
|
103
|
+
success: boolean;
|
|
104
|
+
comment_added: boolean;
|
|
105
|
+
error?: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The ok-arm fields. */
|
|
109
|
+
export interface ResolveOk {
|
|
110
|
+
results: ThreadResultRow[];
|
|
111
|
+
resolved_thread_ids: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The partial-failure branch carries the per-thread detail on the fail arm too. */
|
|
115
|
+
export interface ResolveFailExtras {
|
|
116
|
+
results?: ThreadResultRow[];
|
|
117
|
+
resolved_thread_ids?: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ResolveResult = Result<ResolveOk, ResolveFailExtras>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Narrow the cold door's `results` array to per-thread rows. Strict per row on `thread_id`,
|
|
124
|
+
* `success`, `comment_added`; lenient on the report-only `error` (wrong-typed coerces to null).
|
|
125
|
+
* Any malformed row → null (uncertainty ⇒ no half-rendered partial table).
|
|
126
|
+
*/
|
|
127
|
+
function decodeRows(payload: ColdJson): ThreadResultRow[] | null {
|
|
128
|
+
const raw = payload.results;
|
|
129
|
+
if (!Array.isArray(raw)) return null;
|
|
130
|
+
const rows: ThreadResultRow[] = [];
|
|
131
|
+
for (const item of raw) {
|
|
132
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
|
|
133
|
+
const row = item as ColdJson;
|
|
134
|
+
const threadId = stringField(row, "thread_id");
|
|
135
|
+
const success = booleanField(row, "success");
|
|
136
|
+
const commentAdded = booleanField(row, "comment_added");
|
|
137
|
+
if (threadId === undefined || success === undefined || commentAdded === undefined) return null;
|
|
138
|
+
rows.push({
|
|
139
|
+
thread_id: threadId,
|
|
140
|
+
success,
|
|
141
|
+
comment_added: commentAdded,
|
|
142
|
+
error: nullableStringField(row, "error") ?? null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a batch of review threads (the parent's mechanical resolve step). Delegates to the Python
|
|
150
|
+
* cold door; returns a soft result (never throws). On success, records `last_review_batch`.
|
|
151
|
+
*/
|
|
152
|
+
export async function resolveReviewThreads(
|
|
153
|
+
pi: ExtensionAPI,
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
params: ResolveParams,
|
|
156
|
+
): Promise<ResolveResult> {
|
|
157
|
+
const fail = failFor(ctx, "address", "resolve_review_threads");
|
|
158
|
+
|
|
159
|
+
const threads = Array.isArray(params?.threads) ? params.threads : [];
|
|
160
|
+
if (threads.length === 0) {
|
|
161
|
+
return fail("no threads to resolve (pass { threads: [{thread_id, comment?}] })", "bad_input");
|
|
162
|
+
}
|
|
163
|
+
const batch = threads.map((t) => ({ thread_id: t.thread_id, comment: t.comment ?? null }));
|
|
164
|
+
|
|
165
|
+
const r = await runColdDoor<ThreadResultRow[]>(pi, ctx, ["pr", "resolve-threads", "--json"], {
|
|
166
|
+
label: "perk pr resolve-threads",
|
|
167
|
+
decode: decodeRows,
|
|
168
|
+
stdin: {
|
|
169
|
+
flag: "--batch",
|
|
170
|
+
content: `${JSON.stringify(batch, null, 2)}\n`,
|
|
171
|
+
filename: `resolve-batch-${Date.now()}.json`,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!r.ok) {
|
|
176
|
+
// A partial/failed batch is loud-but-soft: surface the per-thread detail, do not throw. The
|
|
177
|
+
// detail rides the failure envelope's payload; absent/malformed rows ⇒ plain fail (advisory
|
|
178
|
+
// drop — never a half-rendered partial table).
|
|
179
|
+
const rows = r.payload !== undefined ? decodeRows(r.payload) : null;
|
|
180
|
+
if (r.payload === undefined || rows === null) return fail(r.message, r.errorType);
|
|
181
|
+
const resolvedIds = rows.filter((row) => row.success).map((row) => row.thread_id);
|
|
182
|
+
const failed = rows.filter((row) => !row.success).length;
|
|
183
|
+
const error = stringField(r.payload, "message") ?? `${failed} thread(s) did not resolve`;
|
|
184
|
+
report(ctx, "address", "error", error, { alsoLog: true });
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: `Resolved ${resolvedIds.length}/${rows.length} thread(s); ${failed} failed.`,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
details: {
|
|
193
|
+
ok: false,
|
|
194
|
+
error,
|
|
195
|
+
error_type: stringField(r.payload, "error_type") ?? "partial_failure",
|
|
196
|
+
results: rows,
|
|
197
|
+
resolved_thread_ids: resolvedIds,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const results = r.data;
|
|
203
|
+
const resolvedIds = results.filter((row) => row.success).map((row) => row.thread_id);
|
|
204
|
+
|
|
205
|
+
// Record the batch (tier-3, best-effort-with-logging, idempotent, headless-safe). Strict
|
|
206
|
+
// read-back via rebuild — loud-but-non-fatal, the resolve already succeeded.
|
|
207
|
+
const recordedBatch = {
|
|
208
|
+
pr: params.pr ?? null,
|
|
209
|
+
counts: params.counts ?? null,
|
|
210
|
+
resolved_thread_ids: resolvedIds,
|
|
211
|
+
at: new Date().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
appendWorkflowState(pi, ctx, {
|
|
214
|
+
data: { last_review_batch: recordedBatch },
|
|
215
|
+
field: "last_review_batch",
|
|
216
|
+
expected: recordedBatch,
|
|
217
|
+
scope: "address",
|
|
218
|
+
failure: "last_review_batch read-back failed",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return ok(`Resolved ${resolvedIds.length} review thread(s).`, {
|
|
222
|
+
results,
|
|
223
|
+
resolved_thread_ids: resolvedIds,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const TOOL_GUIDELINES = [
|
|
228
|
+
"Call resolve_review_threads only AFTER you have applied (and committed) fixes for the actionable items — it replies-then-resolves the threads you pass.",
|
|
229
|
+
"Pass threads as [{thread_id, comment?}] using the thread_id values from the perk.review-classifier child's structured output; the optional comment is posted as a reply before resolving.",
|
|
230
|
+
"Judgment and edits stay with you (the parent) — never delegate the fix; the spawned classifier is read-only and classification-only.",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
/** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). The converged
|
|
234
|
+
* address body carries the PR identity, so the warm door must resolve a ref — and `/address`
|
|
235
|
+
* cannot function without one regardless (the classifier child's `perk pr feedback` hard-errors
|
|
236
|
+
* `no_plan_ref`). Mirrors `doors/learn.ts`'s helper. */
|
|
237
|
+
function activePlanRef(ctx: ExtensionContext): PlanRef | null {
|
|
238
|
+
const fromWorktree = readPlanRef(ctx.cwd);
|
|
239
|
+
if (fromWorktree) return fromWorktree;
|
|
240
|
+
try {
|
|
241
|
+
const branch = branchOf(ctx);
|
|
242
|
+
return (rebuildWorkflowState(branch).active_plan_ref as PlanRef | null) ?? null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Inject the address-workflow guidance the model follows (the perk-address skill pointer is
|
|
249
|
+
* delivered by the skill-binding suffix — not hardcoded here). When `model` is set, the
|
|
250
|
+
* `perk.review-classifier` spawn carries an inline `model` override ([subagents] review-classifier);
|
|
251
|
+
* otherwise the agent's frontmatter default is used.
|
|
252
|
+
*
|
|
253
|
+
* The wording lives in the shared canonical templates `prompts/stages/address/*` rendered via the
|
|
254
|
+
* cross-plane render seam (contracts.md §8.31) — the warm door converges onto the SAME two
|
|
255
|
+
* templates as the cold `_address_prompt` and the worker `initialPromptFor("address")`. Branching
|
|
256
|
+
* stays in code: preview/action selects the template; classifier present/absent builds the
|
|
257
|
+
* `model_clause` render var. */
|
|
258
|
+
export function addressGuidance(ref: PlanRef, preview: boolean, model?: string): string {
|
|
259
|
+
const modelClause = model
|
|
260
|
+
? `, passing \`model: "${model}"\` on that call (the configured [subagents] review-classifier model)`
|
|
261
|
+
: "";
|
|
262
|
+
const variables = {
|
|
263
|
+
provider: ref.provider,
|
|
264
|
+
pr_id: String(ref.pr_id),
|
|
265
|
+
url: ref.url,
|
|
266
|
+
model_clause: modelClause,
|
|
267
|
+
};
|
|
268
|
+
return render(preview ? "stages/address/preview.md" : "stages/address/action.md", variables);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Register the warm door: the `resolve_review_threads` tool + the `/address` command. */
|
|
272
|
+
export function registerAddress(pi: ExtensionAPI): void {
|
|
273
|
+
pi.registerTool({
|
|
274
|
+
name: "resolve_review_threads",
|
|
275
|
+
label: "Resolve review threads",
|
|
276
|
+
description:
|
|
277
|
+
"Reply-then-resolve a batch of PR review threads after the actionable feedback is fixed. " +
|
|
278
|
+
"Delegates the GitHub mutation to the perk cold door; records the batch in workflow-state.",
|
|
279
|
+
promptSnippet: "Batch-resolve the addressed PR review threads",
|
|
280
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
281
|
+
executionMode: "sequential",
|
|
282
|
+
parameters: {
|
|
283
|
+
type: "object",
|
|
284
|
+
additionalProperties: false,
|
|
285
|
+
required: ["threads"],
|
|
286
|
+
properties: {
|
|
287
|
+
threads: {
|
|
288
|
+
type: "array",
|
|
289
|
+
description: "The threads to resolve.",
|
|
290
|
+
items: {
|
|
291
|
+
type: "object",
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
required: ["thread_id"],
|
|
294
|
+
properties: {
|
|
295
|
+
thread_id: { type: "string", description: "The GraphQL node id of the thread." },
|
|
296
|
+
comment: { type: "string", description: "Optional reply posted before resolving." },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
pr: { type: "number", description: "Optional PR number, recorded in last_review_batch." },
|
|
301
|
+
counts: {
|
|
302
|
+
type: "object",
|
|
303
|
+
description: "Optional classification counts, recorded in last_review_batch.",
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
properties: {
|
|
306
|
+
actionable: { type: "number" },
|
|
307
|
+
informational: { type: "number" },
|
|
308
|
+
praise: { type: "number" },
|
|
309
|
+
question: { type: "number" },
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
315
|
+
const decoded = decodeResolveParams(params);
|
|
316
|
+
if (decoded === null) {
|
|
317
|
+
return failFor(
|
|
318
|
+
ctx,
|
|
319
|
+
"address",
|
|
320
|
+
"resolve_review_threads",
|
|
321
|
+
)("resolve_review_threads needs { threads: [{thread_id, comment?}] }", "bad_input");
|
|
322
|
+
}
|
|
323
|
+
return resolveReviewThreads(pi, ctx, decoded);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
pi.registerCommand("address", {
|
|
328
|
+
description:
|
|
329
|
+
"Classify PR review feedback (isolated child) and resolve threads (submit → address). " +
|
|
330
|
+
"Pass --preview to classify only (take no action).",
|
|
331
|
+
handler: async (args, ctx) => {
|
|
332
|
+
const preview = /(^|\s)--preview(\s|$)/.test(args ?? "");
|
|
333
|
+
// `/address` needs an active plan-ref (the converged body carries the PR identity, and the
|
|
334
|
+
// classifier child's `perk pr feedback` hard-errors `no_plan_ref` without one). Mirror the
|
|
335
|
+
// /implement guard: warn and send no guidance rather than dead-end downstream.
|
|
336
|
+
const ref = activePlanRef(ctx);
|
|
337
|
+
if (ref == null) {
|
|
338
|
+
report(
|
|
339
|
+
ctx,
|
|
340
|
+
"address",
|
|
341
|
+
"warning",
|
|
342
|
+
"/address needs an active plan-ref — run `perk pr address` / after `/submit`.",
|
|
343
|
+
);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const model = loadPerkConfig(ctx.cwd).subagents["review-classifier"];
|
|
347
|
+
const guidance = addressGuidance(ref, preview, model);
|
|
348
|
+
report(
|
|
349
|
+
ctx,
|
|
350
|
+
"address",
|
|
351
|
+
"info",
|
|
352
|
+
preview ? "--preview (classify only)" : "classify → fix → resolve",
|
|
353
|
+
);
|
|
354
|
+
// Inject the address-workflow guidance as a user message so the model starts the loop.
|
|
355
|
+
// `pi.sendUserMessage` always triggers a turn (the warm entry to the review loop). The
|
|
356
|
+
// perk-address pointer rides the skill-binding suffix.
|
|
357
|
+
pi.sendUserMessage(guidance + bindingSuffix(ctx.cwd, "stage:address"));
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The universal, first-party `ask_user_question` tool. Lets a model interactively ask the
|
|
2
|
+
// human a clarifying question during a turn (free-text, or a multiple-choice selection with an
|
|
3
|
+
// always-present "type a custom answer" escape) and continues its turn with the answer. It is
|
|
4
|
+
// NON-terminating and headless-fail-safe: with no interactive UI it returns a graceful no-user
|
|
5
|
+
// sentinel instead of blocking (the AGENTS.md headless-fail-safe convention).
|
|
6
|
+
//
|
|
7
|
+
// To be callable *during planning* the tool name is added to `READ_ONLY_TOOLS` in toolGating.ts —
|
|
8
|
+
// read-only mode otherwise hides every custom tool (the documented read-only gating trap). The
|
|
9
|
+
// stricter SDK_READ_ONLY_TOOLS (headless child sessions) is intentionally NOT touched.
|
|
10
|
+
//
|
|
11
|
+
// REGISTRATION-TIME VACATING (askuser interface seam). `ask_user_question` is a pluggable provider
|
|
12
|
+
// seam: a repo may select the foreign `@juicesharp/rpiv-ask-user-question` extension via
|
|
13
|
+
// `[providers] askuser = "juicesharp-ask-user"`. That package registers a tool with the IDENTICAL
|
|
14
|
+
// name `ask_user_question`. Tools (unlike commands) do NOT get `:N` suffixes — a same-named tool
|
|
15
|
+
// replaces/warns by extension load order, which is non-deterministic. So under a foreign askuser
|
|
16
|
+
// selection `registerAskUser` registers NOTHING (resolves the provider id once at factory time and
|
|
17
|
+
// early-returns before `pi.registerTool`), leaving exactly one `ask_user_question` standing — the
|
|
18
|
+
// same registration-time vacating proven on the plan seam (`registerPlanMode`). This is an
|
|
19
|
+
// INTERFACE seam: there is no durable artifact to bridge (no `cache.plan-ref`/`perk:checkpoint`
|
|
20
|
+
// analogue), so the adapter is vacate-only (`adapter: null`, no shim, no injected context); the
|
|
21
|
+
// foreign tool self-documents via its own `promptGuidelines`. The foreign package is two-
|
|
22
|
+
// directionally wired by `_converge_provider_packages` (installed only when selected, removed on
|
|
23
|
+
// deselect), so under the default (`perk-ask-user`) the foreign package is never loaded and perk's
|
|
24
|
+
// tool is the sole registrant — the default/fail-safe path is the hard guarantee (zero behavior
|
|
25
|
+
// change).
|
|
26
|
+
//
|
|
27
|
+
// Structure: a pure, injectable core (`runAskUserQuestion`) over a minimal `AskUserUI` surface, plus
|
|
28
|
+
// a thin `registerAskUser` wrapper — mirrors ciExecutor.ts's pure-core + injected-fakes testability.
|
|
29
|
+
|
|
30
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
32
|
+
import {
|
|
33
|
+
loadProviders,
|
|
34
|
+
PERK_ASK_USER_PROVIDER_ID,
|
|
35
|
+
resolveProviders,
|
|
36
|
+
} from "../substrate/providers.ts";
|
|
37
|
+
import { paramsOf, stringArrayParam, stringParam } from "../substrate/toolParams.ts";
|
|
38
|
+
|
|
39
|
+
/** The always-appended escape entry on the select path so preset options never trap the user. */
|
|
40
|
+
export const OTHER_CHOICE = "✏️ Other (type a custom answer)…";
|
|
41
|
+
|
|
42
|
+
/** The minimal structural UI surface the core needs (a subset of `ctx.ui`). */
|
|
43
|
+
export interface AskUserUI {
|
|
44
|
+
select(
|
|
45
|
+
title: string,
|
|
46
|
+
options: string[],
|
|
47
|
+
opts?: { signal?: AbortSignal },
|
|
48
|
+
): Promise<string | undefined>;
|
|
49
|
+
input(
|
|
50
|
+
title: string,
|
|
51
|
+
placeholder?: string,
|
|
52
|
+
opts?: { signal?: AbortSignal },
|
|
53
|
+
): Promise<string | undefined>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The structured `details` surface for the tool result (failure/dismissal are non-throwing). */
|
|
57
|
+
export interface AskDetails {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
/** Whether a real user answer was captured (false for headless / dismissed / empty). */
|
|
60
|
+
answered: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A non-terminating tool result: the model continues its turn with the answer text. */
|
|
64
|
+
export type AskResult = { content: { type: "text"; text: string }[]; details: AskDetails };
|
|
65
|
+
|
|
66
|
+
const DISMISSED_TEXT = "(no answer — the user dismissed the prompt.)";
|
|
67
|
+
const NO_UI_TEXT =
|
|
68
|
+
"(no interactive user available — proceed using your best judgment and state the assumption you made.)";
|
|
69
|
+
const NO_QUESTION_TEXT = "ask_user_question: no question provided.";
|
|
70
|
+
|
|
71
|
+
function answer(text: string, answered: boolean): AskResult {
|
|
72
|
+
return { content: [{ type: "text", text }], details: { ok: true, answered } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pure core: ask the user `question` (optionally with preset `options`) and resolve to a
|
|
77
|
+
* non-terminating tool result carrying the answer text. Fully offline-testable with a fake UI.
|
|
78
|
+
*/
|
|
79
|
+
export async function runAskUserQuestion(args: {
|
|
80
|
+
hasUI: boolean;
|
|
81
|
+
ui: AskUserUI;
|
|
82
|
+
question: string;
|
|
83
|
+
options?: string[];
|
|
84
|
+
signal?: AbortSignal;
|
|
85
|
+
}): Promise<AskResult> {
|
|
86
|
+
const { hasUI, ui, options, signal } = args;
|
|
87
|
+
const question = (args.question ?? "").trim();
|
|
88
|
+
if (!question) return answer(NO_QUESTION_TEXT, false);
|
|
89
|
+
if (!hasUI) return answer(NO_UI_TEXT, false);
|
|
90
|
+
|
|
91
|
+
if (options && options.length > 0) {
|
|
92
|
+
const choice = await ui.select(question, [...options, OTHER_CHOICE], { signal });
|
|
93
|
+
if (choice === undefined) return answer(DISMISSED_TEXT, false);
|
|
94
|
+
if (choice === OTHER_CHOICE) {
|
|
95
|
+
const typed = await ui.input(question, undefined, { signal });
|
|
96
|
+
return typed === undefined ? answer(DISMISSED_TEXT, false) : answer(typed, true);
|
|
97
|
+
}
|
|
98
|
+
return answer(choice, true);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const typed = await ui.input(question, undefined, { signal });
|
|
102
|
+
return typed === undefined ? answer(DISMISSED_TEXT, false) : answer(typed, true);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Decode unknown `ask_user_question` tool-call params (the tool-boundary seam), in
|
|
107
|
+
* this tool's native graceful vocabulary: `question` absent OR mistyped → "" (routed into
|
|
108
|
+
* `runAskUserQuestion`'s NO_QUESTION_TEXT arm — answered: false, never throws/blocks); `options`
|
|
109
|
+
* mistyped → advisory-dropped to undefined (the free-text path) — a UI affordance, not a durable
|
|
110
|
+
* write (the decided exception to strict-fail).
|
|
111
|
+
*/
|
|
112
|
+
export function decodeAskUserParams(params: unknown): { question: string; options?: string[] } {
|
|
113
|
+
const p = paramsOf(params);
|
|
114
|
+
if (p === null) return { question: "" };
|
|
115
|
+
return {
|
|
116
|
+
question: stringParam(p, "question") ?? "",
|
|
117
|
+
// Advisory drop: a mistyped `options` falls back to the free-text path.
|
|
118
|
+
options: stringArrayParam(p, "options") ?? undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const TOOL_GUIDELINES = [
|
|
123
|
+
"Prefer this during planning to resolve genuine ambiguity rather than guessing.",
|
|
124
|
+
"Ask ONE focused question at a time, and wait for the answer before the next.",
|
|
125
|
+
"Provide `options` when the answer is a choice — a free-text escape is always added.",
|
|
126
|
+
"Explore the codebase first: if a question is answerable from the code, read it instead of asking.",
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The resolved `[providers] askuser` selection id for `cwd`. Fail-safe to the perk-ask-user
|
|
131
|
+
* reference: any load/resolution failure (corrupt bundled set, etc.) returns the reference id so
|
|
132
|
+
* perk's own tool keeps registering — the default path is the hard guarantee. Mirror of
|
|
133
|
+
* `resolvedPlanProviderId`.
|
|
134
|
+
*/
|
|
135
|
+
export function resolvedAskUserProviderId(cwd: string): string {
|
|
136
|
+
try {
|
|
137
|
+
return resolveProviders(loadPerkConfig(cwd).providers, loadProviders()).askuser.id;
|
|
138
|
+
} catch {
|
|
139
|
+
return PERK_ASK_USER_PROVIDER_ID;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether perk's own `ask_user_question` reference is the selected askuser provider for `cwd`. When
|
|
145
|
+
* a foreign askuser provider is selected via `[providers] askuser`, perk's tool vacates (registers
|
|
146
|
+
* nothing) so the foreign same-named tool is the sole registrant.
|
|
147
|
+
*/
|
|
148
|
+
export function isPerkAskUserReferenceSelected(cwd: string): boolean {
|
|
149
|
+
return resolvedAskUserProviderId(cwd) === PERK_ASK_USER_PROVIDER_ID;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Register the universal warm tool: `ask_user_question`. */
|
|
153
|
+
export function registerAskUser(pi: ExtensionAPI): void {
|
|
154
|
+
// Registration-time vacating: under a foreign askuser selection register NOTHING (the foreign
|
|
155
|
+
// package's same-named tool is the sole registrant). Factory-time `process.cwd()` resolution
|
|
156
|
+
// mirrors `registerPlanMode`. The default/fail-safe path registers exactly as today.
|
|
157
|
+
if (resolvedAskUserProviderId(process.cwd()) !== PERK_ASK_USER_PROVIDER_ID) return;
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name: "ask_user_question",
|
|
160
|
+
label: "Ask user",
|
|
161
|
+
description:
|
|
162
|
+
"Ask the human user a clarifying question and return their answer. Supports a free-text " +
|
|
163
|
+
"prompt or a multiple-choice selection (a 'type a custom answer' escape is always added). " +
|
|
164
|
+
"NON-terminating: the turn continues with the answer. With no interactive UI it returns a " +
|
|
165
|
+
"no-user sentinel instead of blocking.",
|
|
166
|
+
promptSnippet: "Ask the user a clarifying question and get their answer",
|
|
167
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
168
|
+
executionMode: "sequential",
|
|
169
|
+
parameters: {
|
|
170
|
+
type: "object",
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
required: ["question"],
|
|
173
|
+
properties: {
|
|
174
|
+
question: { type: "string", description: "The question to ask the user." },
|
|
175
|
+
options: {
|
|
176
|
+
type: "array",
|
|
177
|
+
items: { type: "string" },
|
|
178
|
+
description:
|
|
179
|
+
"Optional preset choices rendered as a selector; a free-text escape is always appended.",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
184
|
+
const { question, options } = decodeAskUserParams(params);
|
|
185
|
+
return runAskUserQuestion({
|
|
186
|
+
hasUI: ctx.hasUI,
|
|
187
|
+
ui: ctx.ui,
|
|
188
|
+
question,
|
|
189
|
+
options,
|
|
190
|
+
signal,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|