@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,285 @@
|
|
|
1
|
+
// The warm `objective_save` door, the objective mirror of planSave.ts. The in-session twin
|
|
2
|
+
// of the Python cold door (`perk objective create`): a deterministic, terminating tool + command
|
|
3
|
+
// that WRAP the existing storage — they do NOT reimplement the GitHub write. `saveObjective()`
|
|
4
|
+
// passes the STRUCTURED roadmap as `--roadmap <json>` (the agent never hand-writes roadmap YAML)
|
|
5
|
+
// and delegates to `perk objective create --json` via the shared cold-door client (`runColdDoor`,
|
|
6
|
+
// the prose rides the run-scratch stdin channel), then links the live session: `active_objective` + a fresh `perk:objective-budget` activation marker
|
|
7
|
+
// (mirrors the `/objective <id>` activation in objective.ts). Failures are loud-but-non-fatal.
|
|
8
|
+
//
|
|
9
|
+
// APPROVAL→SAVE ORCHESTRATION (mirroring planSave.ts's `approvalSave`). The
|
|
10
|
+
// exported `objectiveApprovalSave` seam is the shared APPROVED-review → save flow: re-read the
|
|
11
|
+
// STRUCTURED artifact (`readObjectiveDraft` — never the rendered markdown, never the transcript)
|
|
12
|
+
// → `saveObjective` → D1a gate exit on a successful save (snapshot `gating.isActive()` BEFORE the
|
|
13
|
+
// save; a failed save leaves the gate ON). `plan_review`'s objective arm (planReview.ts) wires
|
|
14
|
+
// its APPROVED outcome into it; the `/objective-save` command is the artifact-first MANUAL
|
|
15
|
+
// FAILSAFE invocation of the same seam, keeping the legacy drive-the-session behavior as the
|
|
16
|
+
// no-draft fallback (objectives have no transcript scrape by design).
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
20
|
+
import {
|
|
21
|
+
booleanField,
|
|
22
|
+
type ColdJson,
|
|
23
|
+
objectField,
|
|
24
|
+
runColdDoor,
|
|
25
|
+
stringField,
|
|
26
|
+
} from "../substrate/coldDoor.ts";
|
|
27
|
+
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
28
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
29
|
+
import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
30
|
+
import { report, type Severity } from "../surfaces/report.ts";
|
|
31
|
+
import { OBJECTIVE_BUDGET_TYPE } from "./objective.ts";
|
|
32
|
+
import {
|
|
33
|
+
decodeObjectiveSaveParams,
|
|
34
|
+
ROADMAP_PARAM_SCHEMA,
|
|
35
|
+
readObjectiveDraft,
|
|
36
|
+
} from "./objectiveDraft.ts";
|
|
37
|
+
|
|
38
|
+
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
|
|
39
|
+
export interface ObjectiveSaveOk {
|
|
40
|
+
/** `id` is the opaque string objective id (GitHub "7", Linear "ENG-7") — §8.21. */
|
|
41
|
+
objective: { id: string; url: string };
|
|
42
|
+
existed: boolean | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ObjectiveSaveResult = Result<ObjectiveSaveOk>;
|
|
46
|
+
|
|
47
|
+
/** The decoded `perk objective create --json` payload slice the warm door consumes. */
|
|
48
|
+
interface ObjectiveCreatePayload {
|
|
49
|
+
objective: { id: string; url: string; existed: boolean | undefined };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Narrow the `perk objective create --json` success payload; strict on `objective`. */
|
|
53
|
+
function decodeObjectiveCreate(payload: ColdJson): ObjectiveCreatePayload | null {
|
|
54
|
+
const objective = objectField(payload, "objective");
|
|
55
|
+
if (objective === undefined) return null;
|
|
56
|
+
const id = stringField(objective, "id");
|
|
57
|
+
const url = stringField(objective, "url");
|
|
58
|
+
if (id === undefined || url === undefined) return null;
|
|
59
|
+
return { objective: { id, url, existed: booleanField(objective, "existed") } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The single save implementation both surfaces call. Delegates the GitHub write to the Python cold
|
|
64
|
+
* door, then links the live session (`active_objective` + budget marker). Returns a soft result
|
|
65
|
+
* (never throws); failures set `details.ok = false` and append no linkage.
|
|
66
|
+
*/
|
|
67
|
+
export async function saveObjective(
|
|
68
|
+
pi: ExtensionAPI,
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
opts: { prose: string; title?: string; roadmap?: unknown[]; base?: string },
|
|
71
|
+
): Promise<ObjectiveSaveResult> {
|
|
72
|
+
const fail = failFor(ctx, "objective-save");
|
|
73
|
+
|
|
74
|
+
const prose = opts.prose.trim();
|
|
75
|
+
if (!prose)
|
|
76
|
+
return fail("no objective prose to save (draft the objective first)", "invalid_input");
|
|
77
|
+
if (opts.roadmap !== undefined && !Array.isArray(opts.roadmap)) {
|
|
78
|
+
return fail("roadmap must be a JSON array of nodes", "invalid_input");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const branch = () => branchOf(ctx);
|
|
82
|
+
const runId = rebuildWorkflowState(branch()).run_id ?? "";
|
|
83
|
+
|
|
84
|
+
const args = ["objective", "create", "--json"];
|
|
85
|
+
if (opts.title) args.push("--title", opts.title);
|
|
86
|
+
if (opts.base) args.push("--base", opts.base);
|
|
87
|
+
if (runId) args.push("--run-id", runId);
|
|
88
|
+
if (opts.roadmap && opts.roadmap.length > 0) {
|
|
89
|
+
args.push("--roadmap", JSON.stringify(opts.roadmap));
|
|
90
|
+
}
|
|
91
|
+
const r = await runColdDoor<ObjectiveCreatePayload>(pi, ctx, args, {
|
|
92
|
+
label: "perk objective create",
|
|
93
|
+
decode: decodeObjectiveCreate,
|
|
94
|
+
stdin: { flag: "--body", content: prose, filename: "objective.md" },
|
|
95
|
+
});
|
|
96
|
+
if (!r.ok) return fail(r.message, r.errorType);
|
|
97
|
+
|
|
98
|
+
// Link the live session: set active_objective (LWW) + seed a fresh budget activation marker
|
|
99
|
+
// (mirrors objective.ts's `/objective <id>` activation), so budget tracking starts immediately.
|
|
100
|
+
// The envelope id is already the opaque string id (§8.21) — no coercion needed.
|
|
101
|
+
const objective = r.data.objective;
|
|
102
|
+
const objectiveId = objective.id;
|
|
103
|
+
const linked = rebuildWorkflowState(branch()).active_objective ?? null;
|
|
104
|
+
if (linked !== objectiveId) {
|
|
105
|
+
appendWorkflowState(pi, ctx, {
|
|
106
|
+
data: { active_objective: objectiveId },
|
|
107
|
+
field: "active_objective",
|
|
108
|
+
expected: objectiveId,
|
|
109
|
+
scope: "objective-save",
|
|
110
|
+
failure: `active_objective read-back failed for #${objectiveId}`,
|
|
111
|
+
});
|
|
112
|
+
pi.appendEntry(OBJECTIVE_BUDGET_TYPE, {
|
|
113
|
+
objective_id: objectiveId,
|
|
114
|
+
activated_at: new Date().toISOString(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const verb = objective.existed ? "Found existing" : "Saved";
|
|
119
|
+
return ok(
|
|
120
|
+
`${verb} objective #${objective.id} → ${objective.url}`,
|
|
121
|
+
{
|
|
122
|
+
objective: { id: objective.id, url: objective.url },
|
|
123
|
+
existed: objective.existed ?? null,
|
|
124
|
+
},
|
|
125
|
+
{ terminate: true },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The approval→save orchestration outcome (the objective `ApprovalSaveOutcome`). */
|
|
130
|
+
export type ObjectiveApprovalSaveOutcome =
|
|
131
|
+
| { status: "no-draft" }
|
|
132
|
+
| { status: "saved" | "save-failed"; result: ObjectiveSaveResult; gateExited: boolean };
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The shared approval→save orchestration seam (the objective sibling of
|
|
136
|
+
* planSave.ts's `approvalSave`): an APPROVED objective review (`plan_review`'s objective arm)
|
|
137
|
+
* and the manual `/objective-save` failsafe both run THIS. Flow: re-read the STRUCTURED draft
|
|
138
|
+
* artifact at save time (`readObjectiveDraft` — never the rendered markdown, never in-hand
|
|
139
|
+
* bytes) → `saveObjective` → gate exit on a successful save while read-only (the D1a pattern:
|
|
140
|
+
* snapshot `gating.isActive()` before the save; a failed save leaves the gate ON). No draft →
|
|
141
|
+
* `no-draft` (nothing saved, the gate untouched); callers render their own fallback. Title
|
|
142
|
+
* precedence: an explicit `opts.title` wins; else the draft's `title`; else the cold door
|
|
143
|
+
* derives from the prose heading. The returned result keeps `saveObjective`'s `terminate: true`
|
|
144
|
+
* for tool-path callers.
|
|
145
|
+
*/
|
|
146
|
+
export async function objectiveApprovalSave(
|
|
147
|
+
pi: ExtensionAPI,
|
|
148
|
+
ctx: ExtensionContext,
|
|
149
|
+
gating: ToolGating,
|
|
150
|
+
opts: { title?: string } = {},
|
|
151
|
+
): Promise<ObjectiveApprovalSaveOutcome> {
|
|
152
|
+
const draft = readObjectiveDraft(ctx);
|
|
153
|
+
if (draft === null) return { status: "no-draft" };
|
|
154
|
+
// D1a: snapshot the gate BEFORE the save; on success, exit it so save marks the read-only →
|
|
155
|
+
// read-write boundary in one gesture. A failed save leaves the gate on.
|
|
156
|
+
const wasReadOnly = gating.isActive();
|
|
157
|
+
const result = await saveObjective(pi, ctx, {
|
|
158
|
+
prose: draft.prose,
|
|
159
|
+
title: opts.title ?? draft.title,
|
|
160
|
+
roadmap: draft.roadmap,
|
|
161
|
+
base: draft.base,
|
|
162
|
+
});
|
|
163
|
+
let gateExited = false;
|
|
164
|
+
if (result.details.ok && wasReadOnly) {
|
|
165
|
+
gating.exit(ctx);
|
|
166
|
+
gateExited = true;
|
|
167
|
+
}
|
|
168
|
+
return { status: result.details.ok ? "saved" : "save-failed", result, gateExited };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const TOOL_GUIDELINES = [
|
|
172
|
+
"Use objective_save only after the objective + roadmap are decision-complete; it creates the canonical perk:objective issue, activates it, and ends the turn.",
|
|
173
|
+
"Pass the objective PROSE in `prose` and the STRUCTURED roadmap in `roadmap` (a JSON array of nodes) — never hand-write roadmap YAML.",
|
|
174
|
+
'Each roadmap node needs a stable `id` (e.g. "1.1") and a `description`; `status` defaults to pending. Use `depends_on` for explicit ordering.',
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The seed guidance the warm `/objective-save` injects to drive the structured save (the
|
|
179
|
+
* perk-objective-author skill pointer rides the skill-binding suffix — not hardcoded
|
|
180
|
+
* here). Pure + exported for offline tests.
|
|
181
|
+
*/
|
|
182
|
+
export function objectiveSaveGuidance(title?: string): string {
|
|
183
|
+
const named = title?.trim();
|
|
184
|
+
return [
|
|
185
|
+
"perk /objective-save — persist the objective the session converged on.",
|
|
186
|
+
"1. If the objective + roadmap are NOT yet decision-complete, finish converging first, then " +
|
|
187
|
+
"call the tool.",
|
|
188
|
+
"2. Call the `objective_save` tool NOW, passing `prose` (the decision-complete objective " +
|
|
189
|
+
"prose) and `roadmap` (the STRUCTURED roadmap as a JSON array of nodes, each with a stable " +
|
|
190
|
+
"`id` and `description`) — NEVER hand-write the roadmap as YAML.",
|
|
191
|
+
named
|
|
192
|
+
? `3. Pass \`title: "${named}"\` as the objective title.`
|
|
193
|
+
: "3. `title` is optional (defaults to the prose's first heading).",
|
|
194
|
+
"4. The tool creates the perk:objective issue, activates it, starts budget tracking, and " +
|
|
195
|
+
"terminates the turn. Judgment + durable writes stay with you.",
|
|
196
|
+
].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Register the warm door: the `objective_save` tool (canonical) + the `/objective-save` twin. */
|
|
200
|
+
export function registerObjectiveSave(pi: ExtensionAPI, gating: ToolGating): void {
|
|
201
|
+
pi.registerTool({
|
|
202
|
+
name: "objective_save",
|
|
203
|
+
label: "Save objective",
|
|
204
|
+
description:
|
|
205
|
+
"Persist a drafted objective + structured roadmap to GitHub as a perk:objective issue, " +
|
|
206
|
+
"activate it, and start budget tracking. Terminating: ends the turn on save. Call only when " +
|
|
207
|
+
"the objective and roadmap are decision-complete.",
|
|
208
|
+
promptSnippet: "Save the decision-complete objective + roadmap to GitHub (terminates the turn)",
|
|
209
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
210
|
+
executionMode: "sequential",
|
|
211
|
+
parameters: {
|
|
212
|
+
type: "object",
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
required: ["prose"],
|
|
215
|
+
properties: {
|
|
216
|
+
prose: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "The objective prose (the why, the design, the boundaries/non-goals).",
|
|
219
|
+
},
|
|
220
|
+
title: {
|
|
221
|
+
type: "string",
|
|
222
|
+
description: "Optional objective title (defaults to the prose's first heading).",
|
|
223
|
+
},
|
|
224
|
+
base: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description:
|
|
227
|
+
"Optional target branch for this objective's plans (omit to use the repo default).",
|
|
228
|
+
},
|
|
229
|
+
roadmap: {
|
|
230
|
+
type: "array",
|
|
231
|
+
description:
|
|
232
|
+
"The structured roadmap: a JSON array of nodes. Never hand-write roadmap YAML.",
|
|
233
|
+
items: ROADMAP_PARAM_SCHEMA,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
238
|
+
const decoded = decodeObjectiveSaveParams(params);
|
|
239
|
+
if (decoded === null) {
|
|
240
|
+
return failFor(
|
|
241
|
+
ctx,
|
|
242
|
+
"objective-save",
|
|
243
|
+
"objective_save",
|
|
244
|
+
)(
|
|
245
|
+
"objective_save needs { prose: string, roadmap?: array } per the tool schema",
|
|
246
|
+
"bad_input",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return saveObjective(pi, ctx, decoded);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.registerCommand("objective-save", {
|
|
254
|
+
description:
|
|
255
|
+
"Save the working objective draft to GitHub — the manual failsafe for the approval→save " +
|
|
256
|
+
"flow (artifact-first; drives the structured save only when no draft exists).",
|
|
257
|
+
handler: async (args, ctx) => {
|
|
258
|
+
const title = args.trim() || undefined;
|
|
259
|
+
// The artifact-first manual-failsafe invocation of the shared approval→save
|
|
260
|
+
// seam (the D1a gate exit lives in the seam). The legacy drive-the-session behavior is kept
|
|
261
|
+
// as the NO-DRAFT fallback — objectives have no transcript scrape by design, so a draftless
|
|
262
|
+
// session still needs a working save path.
|
|
263
|
+
const outcome = await objectiveApprovalSave(pi, ctx, gating, { title });
|
|
264
|
+
if (outcome.status === "no-draft") {
|
|
265
|
+
// Exit the read-only gate so the objective_save tool (excluded from READ_ONLY_TOOLS)
|
|
266
|
+
// becomes reachable on the driven turn, then drive the turn (mirrors /address and
|
|
267
|
+
// /objective-plan).
|
|
268
|
+
if (gating.isActive()) gating.exit(ctx);
|
|
269
|
+
report(ctx, "objective-save", "info", "handing the structured save to the session");
|
|
270
|
+
// The perk-objective-author pointer rides the skill-binding suffix (D5) since a
|
|
271
|
+
// warm /objective-save outside a stage:objective-author session gets none from Mechanism A.
|
|
272
|
+
pi.sendUserMessage(
|
|
273
|
+
objectiveSaveGuidance(title) + bindingSuffix(ctx.cwd, "stage:objective-author"),
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Saved or save-failed: relay the save message. No node-link sub-step on the objective path,
|
|
278
|
+
// so the severity ladder is simpler than /plan-save's (no warning tier).
|
|
279
|
+
const result = outcome.result;
|
|
280
|
+
const message = result.content[0]?.text ?? "objective-save done";
|
|
281
|
+
const severity: Severity = result.details.ok ? "info" : "error";
|
|
282
|
+
report(ctx, "objective-save", severity, message);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The `plan_draft` file tool: the first session-data PRODUCER and the
|
|
2
|
+
// narrow structural read-only-gate carve-out (session data dir only).
|
|
3
|
+
//
|
|
4
|
+
// Carve-out doctrine: the tool takes NO path/name parameter — the artifact name is the fixed
|
|
5
|
+
// constant `PLAN_DRAFT_ARTIFACT` and the path is derived exclusively through the session-data
|
|
6
|
+
// accessor seam (`writeSessionArtifact`, sessionData.ts), so the only bytes it can ever write are
|
|
7
|
+
// the one working-plan artifact in the current run's data dir (gitignored scratch). Allowlisting
|
|
8
|
+
// its name in `READ_ONLY_TOOLS` (toolGating.ts) is therefore safe: the read-only invariant (the
|
|
9
|
+
// worktree stays untouched) holds, and the gate's `tool_call` edit/write/bash blocking logic is
|
|
10
|
+
// UNCHANGED. Full rewrite per call, non-terminating; NOT a save — `plan_save`/`/plan-save` still
|
|
11
|
+
// persist to GitHub. Consumers read the draft only via `readSessionArtifact` (digest-validated,
|
|
12
|
+
// fail-open); the artifact is consumed by `resolvePlanSource` (planSave.ts) — both save
|
|
13
|
+
// surfaces prefer it over an explicit param and over the transcript scrape.
|
|
14
|
+
//
|
|
15
|
+
// Imports stay node builtins + sibling seams (sessionData.ts, toolParams.ts, result.ts) so the
|
|
16
|
+
// module loads under `node --test`; no manual `scratch`/`runs` path segments (cacheGuard.test.ts).
|
|
17
|
+
|
|
18
|
+
import { relative } from "node:path";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
21
|
+
import {
|
|
22
|
+
activeSessionRunId,
|
|
23
|
+
digestSessionData,
|
|
24
|
+
type SessionDataCtx,
|
|
25
|
+
writeSessionArtifact,
|
|
26
|
+
} from "../substrate/sessionData.ts";
|
|
27
|
+
import { paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
28
|
+
import type { EntrySink } from "../substrate/workflowState.ts";
|
|
29
|
+
import type { ReportTarget } from "../surfaces/report.ts";
|
|
30
|
+
|
|
31
|
+
/** The fixed working-plan artifact name (NOT `plan.md` — `cache.plan` is a different file). */
|
|
32
|
+
export const PLAN_DRAFT_ARTIFACT = "plan-draft.md";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decode unknown `plan_draft` tool-call params (the tool-boundary seam). `plan` absent decodes to
|
|
36
|
+
* `""` (so the core's `invalid_input` arm owns the empty-plan message); present-but-mistyped →
|
|
37
|
+
* null (strict-fail `bad_input`). Decode-before-side-effect.
|
|
38
|
+
*/
|
|
39
|
+
export function decodePlanDraftParams(params: unknown): { plan: string } | null {
|
|
40
|
+
const p = paramsOf(params);
|
|
41
|
+
if (p === null) return null;
|
|
42
|
+
const plan = stringParam(p, "plan");
|
|
43
|
+
if (plan === null) return null;
|
|
44
|
+
return { plan: plan ?? "" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The ok-arm details — provenance-consistent with the recorded `session_artifacts` pointer. */
|
|
48
|
+
export interface PlanDraftOk {
|
|
49
|
+
name: string;
|
|
50
|
+
path: string;
|
|
51
|
+
digest: string;
|
|
52
|
+
bytes: number;
|
|
53
|
+
run_id: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type PlanDraftResult = Result<PlanDraftOk>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The core both the tool handler and tests call: write the working-plan artifact through the
|
|
60
|
+
* accessor seam (file + `session_artifacts` provenance pointer). Soft result, never throws —
|
|
61
|
+
* failure taxonomy: empty plan → `invalid_input`; no session run_id → `no_run_id`; file-or-pointer
|
|
62
|
+
* write failure → `write_failed` (the seam already warned on stderr).
|
|
63
|
+
*/
|
|
64
|
+
export function writePlanDraft(
|
|
65
|
+
sink: EntrySink,
|
|
66
|
+
ctx: SessionDataCtx & ReportTarget,
|
|
67
|
+
plan: string,
|
|
68
|
+
): PlanDraftResult {
|
|
69
|
+
const fail = failFor(ctx, "plan-draft");
|
|
70
|
+
|
|
71
|
+
if (!plan.trim()) {
|
|
72
|
+
return fail("no plan markdown to write (pass the full working draft)", "invalid_input");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const runId = activeSessionRunId(ctx);
|
|
76
|
+
if (runId === null) {
|
|
77
|
+
return fail("session has no run_id — cannot write the plan-draft artifact", "no_run_id");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const written = writeSessionArtifact(sink, ctx, PLAN_DRAFT_ARTIFACT, plan);
|
|
81
|
+
if (written === null) {
|
|
82
|
+
return fail(
|
|
83
|
+
`could not write the ${PLAN_DRAFT_ARTIFACT} artifact (see warnings)`,
|
|
84
|
+
"write_failed",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Derive digest/relative path consistently with the pointer the seam recorded.
|
|
89
|
+
const digest = digestSessionData(plan);
|
|
90
|
+
const relPath = relative(ctx.cwd, written);
|
|
91
|
+
return ok(`Plan draft written → ${relPath} (${digest})`, {
|
|
92
|
+
name: PLAN_DRAFT_ARTIFACT,
|
|
93
|
+
path: relPath,
|
|
94
|
+
digest,
|
|
95
|
+
bytes: Buffer.byteLength(plan, "utf8"),
|
|
96
|
+
run_id: runId,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const TOOL_GUIDELINES = [
|
|
101
|
+
"Call plan_draft to persist the current working draft as you author or revise the plan; pass the FULL plan markdown each time (it rewrites the whole draft).",
|
|
102
|
+
"plan_draft never saves to GitHub and never ends the turn — plan_save//plan-save remain the canonical save surface.",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/** Register the `plan_draft` tool (the carve-out producer; interior-only). */
|
|
106
|
+
export function registerPlanDraft(pi: ExtensionAPI): void {
|
|
107
|
+
pi.registerTool({
|
|
108
|
+
name: "plan_draft",
|
|
109
|
+
label: "Plan draft",
|
|
110
|
+
description:
|
|
111
|
+
"Write (or overwrite) the working plan draft to the session data dir and record its " +
|
|
112
|
+
"provenance pointer. The only sanctioned write surface while read-only. NOT a save — " +
|
|
113
|
+
"plan_save//plan-save still persist the plan to GitHub.",
|
|
114
|
+
promptSnippet: "Persist the working plan draft to the session data dir (full rewrite)",
|
|
115
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
116
|
+
executionMode: "sequential",
|
|
117
|
+
parameters: {
|
|
118
|
+
type: "object",
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
required: ["plan"],
|
|
121
|
+
properties: {
|
|
122
|
+
plan: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: "The full working-plan markdown (rewrites the whole draft).",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
129
|
+
const decoded = decodePlanDraftParams(params);
|
|
130
|
+
if (decoded === null) {
|
|
131
|
+
return failFor(
|
|
132
|
+
ctx,
|
|
133
|
+
"plan-draft",
|
|
134
|
+
"plan_draft",
|
|
135
|
+
)("plan_draft needs { plan: string } per the tool schema", "bad_input");
|
|
136
|
+
}
|
|
137
|
+
return writePlanDraft(pi, ctx, decoded.plan);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// perk-owned plan mode. The first consumer of the tool-gating primitive
|
|
2
|
+
// (extension/substrate/toolGating.ts). This is the *toggle surface* on top of that structural gate: a
|
|
3
|
+
// `/plan` command, a `Ctrl+Alt+P` shortcut, and a `--plan` flag all flip `gating.enter`/`exit`.
|
|
4
|
+
// perk owns NO parallel enforcement here — the gate is the single read-only authority.
|
|
5
|
+
//
|
|
6
|
+
// It also injects the cooperative *plan-authoring* prompt layer (the gather-then-plan contract)
|
|
7
|
+
// under its own `perk:plan-context` customType (display:false),
|
|
8
|
+
// keyed directly off the read-only gate (read-only ⟹ plan in the main session). The
|
|
9
|
+
// content is stripped from `context` when the gate is off — the same hygiene the gate applies to its
|
|
10
|
+
// `perk:mode-context`. An optional `[workflow] plan_authoring` config addendum (extension/substrate/config.ts)
|
|
11
|
+
// is appended when present.
|
|
12
|
+
//
|
|
13
|
+
// Grounded in pi's official `examples/extensions/plan-mode/` recipe, but perk adopts ONLY the
|
|
14
|
+
// read-only authoring half: there is no in-session "execution mode" flip — perk separates plan
|
|
15
|
+
// (read-only session) from implement (cold-door fresh worktree session), and `[DONE:n]` tracking
|
|
16
|
+
// lives in the implement session.
|
|
17
|
+
//
|
|
18
|
+
// REGISTRATION-TIME DEFERRAL, now THREE-TIER. `registerPlanMode` resolves the plan
|
|
19
|
+
// provider id once at factory time and branches:
|
|
20
|
+
// - `perk-plan` (and the fail-safe error path) → register EVERYTHING (the default path is the
|
|
21
|
+
// hard guarantee, zero behavior change).
|
|
22
|
+
// - `plannotator-plan` (AUGMENT posture) → register everything EXCEPT the `--plan` flag, the
|
|
23
|
+
// `Ctrl+Alt+P` shortcut, and the `--plan` session_start handler: `@plannotator/pi-extension`
|
|
24
|
+
// also registers that flag + shortcut, and duplicate flag/shortcut registration is the known
|
|
25
|
+
// potentially-fatal Pi behavior — plannotator owns `--plan`/`Ctrl+Alt+P` exclusively while
|
|
26
|
+
// perk keeps `/plan`, the authoring injection, and the read-only gate (plannotator augments
|
|
27
|
+
// perk's plan flow via the planAdapterPlannotator `plan_review` bridge; it does not replace it).
|
|
28
|
+
// - any other foreign id (tombell, REPLACE posture) → register NOTHING; the foreign package owns
|
|
29
|
+
// `/plan`/`Ctrl+Alt+P`/`--plan` unambiguously (Pi suffixes duplicate command names, so
|
|
30
|
+
// handler-time deferral alone is insufficient once the foreign package is loaded).
|
|
31
|
+
|
|
32
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
34
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
35
|
+
import {
|
|
36
|
+
loadProviders,
|
|
37
|
+
PERK_PLAN_PROVIDER_ID,
|
|
38
|
+
PLANNOTATOR_PLAN_PROVIDER_ID,
|
|
39
|
+
resolveProviders,
|
|
40
|
+
} from "../substrate/providers.ts";
|
|
41
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
42
|
+
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
43
|
+
import { report } from "../surfaces/report.ts";
|
|
44
|
+
import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
|
|
45
|
+
|
|
46
|
+
/** The plan-authoring context customType (distinct from the gate's `perk:mode-context`). */
|
|
47
|
+
export const PLAN_CONTEXT_TYPE = "perk:plan-context";
|
|
48
|
+
const PLAN_MARKER = "[PLAN AUTHORING]";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The cooperative gather-then-plan contract. This is prompting, NOT enforcement (the gate is the
|
|
52
|
+
* enforcement). It never leaks internal policy text — it tells the model how to materialize a
|
|
53
|
+
* decision-complete plan an executor with zero prior context can follow (mirrors
|
|
54
|
+
* skills/perk-plan/SKILL.md). Durable anchors only, no line numbers.
|
|
55
|
+
*/
|
|
56
|
+
export const PLAN_AUTHORING_CONTEXT = `${PLAN_MARKER}
|
|
57
|
+
You are authoring a perk plan in read-only mode — explore first, then write.
|
|
58
|
+
|
|
59
|
+
Gather before you plan. Materialize four finding categories from real evidence:
|
|
60
|
+
- Status: what exists today (the current behavior, where it lives).
|
|
61
|
+
- Discoveries: concrete findings with real file paths and function/class names.
|
|
62
|
+
- Corrections: assumptions that turned out wrong, and what is actually true.
|
|
63
|
+
- Codebase evidence: the specific code you verified each decision against.
|
|
64
|
+
|
|
65
|
+
Check \`docs/learned/\` for relevant prior art and gotchas before you plan. The ambient routing
|
|
66
|
+
index in your system prompt points into the full catalog at \`docs/learned/index.md\`; when a
|
|
67
|
+
routing cue matches your change, \`read\` that doc. This is a check, not a requirement — there may
|
|
68
|
+
be nothing relevant to your change, and your plan does not need to be grounded in prior learnings.
|
|
69
|
+
|
|
70
|
+
Write the plan so an executor (a future session, or another engineer) with zero prior context can
|
|
71
|
+
implement it without guessing. Anchor every change durably — function/class names, behavioral
|
|
72
|
+
descriptions, structural locations — never line numbers. Resolve every open choice before saving;
|
|
73
|
+
a saved plan must leave no decisions to the implementer.
|
|
74
|
+
|
|
75
|
+
When the plan is decision-complete, request a human review:
|
|
76
|
+
- Keep the working draft current with plan_draft — the validated plan-draft artifact is what gets
|
|
77
|
+
reviewed AND auto-saved.
|
|
78
|
+
- Call the plan_review tool — the human reviews the plan in the configured review surface (perk's
|
|
79
|
+
in-TUI editor review by default).
|
|
80
|
+
- If the review is DENIED: revise per the feedback, rewrite the draft with plan_draft, then call
|
|
81
|
+
plan_review again.
|
|
82
|
+
- If the review is APPROVED: the plan is auto-saved and the session leaves read-only. Relay the
|
|
83
|
+
save outcome — do NOT re-dump the plan as a final message and do NOT tell the user to run
|
|
84
|
+
/plan-save.
|
|
85
|
+
- If plan_review reports it was skipped or unavailable (headless, dismissed, no surface): present
|
|
86
|
+
the complete plan as your final message; the human runs /plan-save (the manual failsafe).`;
|
|
87
|
+
|
|
88
|
+
/** Build the full plan-authoring injection, appending the project config addendum when present. */
|
|
89
|
+
export function planContextContent(cwd: string): string {
|
|
90
|
+
const addendum = loadPerkConfig(cwd).planAuthoring;
|
|
91
|
+
return addendum ? `${PLAN_AUTHORING_CONTEXT}\n\n${addendum.trim()}` : PLAN_AUTHORING_CONTEXT;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The resolved `[providers] plan` selection id for `cwd`, read fresh per-event (no static state —
|
|
96
|
+
* the same per-event-read shape `planContextContent(ctx.cwd)` uses). Fail-safe to the perk-plan
|
|
97
|
+
* reference: any load/resolution failure (corrupt bundled set, etc.) returns the reference id so
|
|
98
|
+
* perk's own plan mode keeps working — the default path is the hard guarantee.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvedPlanProviderId(cwd: string): string {
|
|
101
|
+
try {
|
|
102
|
+
return resolveProviders(loadPerkConfig(cwd).providers, loadProviders()).plan.id;
|
|
103
|
+
} catch {
|
|
104
|
+
return PERK_PLAN_PROVIDER_ID;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether perk's own plan-mode reference is the selected plan provider for `cwd`. When a foreign
|
|
110
|
+
* plan provider is selected via `[providers] plan`, perk's authoring surface steps aside (defers).
|
|
111
|
+
*/
|
|
112
|
+
export function isPerkPlanReferenceSelected(cwd: string): boolean {
|
|
113
|
+
return resolvedPlanProviderId(cwd) === PERK_PLAN_PROVIDER_ID;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register the perk-owned plan-mode toggle surface over the read-only gate. Idempotent enter/exit (the gate
|
|
118
|
+
* tracks its own on/off transition), fail-safe-headless (notify when UI, else stderr).
|
|
119
|
+
*/
|
|
120
|
+
export function registerPlanMode(pi: ExtensionAPI, gating: ToolGating): void {
|
|
121
|
+
// Three-tier registration branch (see the header comment): full registration for the reference
|
|
122
|
+
// (fail-safe default), a PARTIAL vacate (skip only `--plan` + `Ctrl+Alt+P`) under the augment-
|
|
123
|
+
// posture plannotator selection, and a full vacate under any other foreign selection (tombell).
|
|
124
|
+
const providerId = resolvedPlanProviderId(process.cwd());
|
|
125
|
+
const plannotatorSelected = providerId === PLANNOTATOR_PLAN_PROVIDER_ID;
|
|
126
|
+
if (providerId !== PERK_PLAN_PROVIDER_ID && !plannotatorSelected) return;
|
|
127
|
+
|
|
128
|
+
if (!plannotatorSelected) {
|
|
129
|
+
pi.registerFlag("plan", {
|
|
130
|
+
description: "Start in perk plan mode (read-only exploration + plan authoring).",
|
|
131
|
+
type: "boolean",
|
|
132
|
+
default: false,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function announce(ctx: ExtensionContext, on: boolean): void {
|
|
137
|
+
const message = on
|
|
138
|
+
? "plan mode ON — read-only exploration; author the plan, then review with plan_review (approval auto-saves; /plan-save is the manual failsafe)."
|
|
139
|
+
: "plan mode OFF — full tool access restored.";
|
|
140
|
+
report(ctx, "plan-mode", "info", message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toggle(ctx: ExtensionContext): void {
|
|
144
|
+
if (gating.isActive()) {
|
|
145
|
+
gating.exit(ctx);
|
|
146
|
+
announce(ctx, false);
|
|
147
|
+
} else {
|
|
148
|
+
gating.enter(ctx);
|
|
149
|
+
announce(ctx, true);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pi.registerCommand("plan", {
|
|
154
|
+
description: "Toggle perk plan mode (read-only exploration + plan authoring).",
|
|
155
|
+
handler: async (_args, ctx) => toggle(ctx),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!plannotatorSelected) {
|
|
159
|
+
pi.registerShortcut(Key.ctrlAlt("p"), {
|
|
160
|
+
description: "Toggle perk plan mode",
|
|
161
|
+
handler: async (ctx) => toggle(ctx),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// `--plan` cold start: enter read-only on session_start when the flag is set and the gate is
|
|
165
|
+
// off. (index.ts's session_start already syncs the gate from the rebuilt `mode`; this layers
|
|
166
|
+
// the flag on top for ad-hoc `pi --plan` interactive starts — the cold plan door drives
|
|
167
|
+
// read-only via the handoff `mode`, not this flag.) Skipped under the plannotator selection
|
|
168
|
+
// along with the flag itself (the flag no longer exists on perk's side).
|
|
169
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
170
|
+
if (pi.getFlag("plan") === true && !gating.isActive()) {
|
|
171
|
+
gating.enter(ctx);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inject the plan-authoring context while the read-only gate is active (display:false). The one
|
|
177
|
+
// exception: an objective-author session is ALSO read-only, but objectiveAuthor.ts injects its
|
|
178
|
+
// own authoring context there — so plan mode defers when the launched stage is objective-author
|
|
179
|
+
// (the coupling break: plan-authoring context is no longer keyed off the bare read-only gate).
|
|
180
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
181
|
+
if (!gating.isActive()) return;
|
|
182
|
+
const branch = branchOf(ctx);
|
|
183
|
+
if (rebuildWorkflowState(branch).stage === OBJECTIVE_AUTHOR_STAGE) return;
|
|
184
|
+
return {
|
|
185
|
+
message: {
|
|
186
|
+
customType: PLAN_CONTEXT_TYPE,
|
|
187
|
+
content: planContextContent(ctx.cwd),
|
|
188
|
+
display: false,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Strip the stale plan-authoring marker from context when the gate is off (so it never lingers).
|
|
194
|
+
pi.on("context", async (event) => {
|
|
195
|
+
if (gating.isActive()) return;
|
|
196
|
+
return {
|
|
197
|
+
messages: event.messages.filter((m) => {
|
|
198
|
+
const msg = m as { customType?: string; role?: string; content?: unknown };
|
|
199
|
+
if (msg.customType === PLAN_CONTEXT_TYPE) return false;
|
|
200
|
+
if (msg.role !== "user") return true;
|
|
201
|
+
const content = msg.content;
|
|
202
|
+
if (typeof content === "string") return !content.includes(PLAN_MARKER);
|
|
203
|
+
if (Array.isArray(content)) {
|
|
204
|
+
return !content.some(
|
|
205
|
+
(c) =>
|
|
206
|
+
(c as { type?: string; text?: string }).type === "text" &&
|
|
207
|
+
((c as { text?: string }).text ?? "").includes(PLAN_MARKER),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|