@mgiles/perk 2.1.0 → 2.3.0

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.
Files changed (50) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +64 -1
  2. package/extension/doors/address.ts +3 -3
  3. package/extension/doors/commitCompact.ts +163 -0
  4. package/extension/doors/learn.ts +219 -23
  5. package/extension/doors/prReview.ts +189 -18
  6. package/extension/doors/prReviewDynamic.ts +249 -0
  7. package/extension/doors/submit.ts +4 -3
  8. package/extension/factories/gistAuthor.ts +94 -0
  9. package/extension/factories/gistDraft.ts +265 -0
  10. package/extension/factories/gistSave.ts +251 -0
  11. package/extension/factories/objectivePlan.ts +3 -2
  12. package/extension/factories/planMode.ts +8 -5
  13. package/extension/factories/planReview.ts +233 -12
  14. package/extension/index.ts +26 -0
  15. package/extension/substrate/config.ts +8 -4
  16. package/extension/substrate/git.ts +38 -0
  17. package/extension/substrate/terminalLaunch.ts +1 -1
  18. package/extension/substrate/toolGating.ts +44 -3
  19. package/extension/substrate/unifiedDiff.ts +224 -0
  20. package/extension/waves/learnWave.ts +155 -0
  21. package/extension/waves/memoryAdapter.ts +126 -0
  22. package/extension/waves/prReviewDynamicWave.ts +466 -0
  23. package/extension/waves/prReviewWave.ts +229 -0
  24. package/extension/waves/reportWave.ts +449 -0
  25. package/extension/waves/rpcAdapter.ts +201 -0
  26. package/package.json +7 -1
  27. package/prompts/_fixtures/live.yaml +22 -11
  28. package/prompts/commit-and-compact.md +7 -0
  29. package/prompts/common/output-schemas/objective-explorer.md +36 -0
  30. package/prompts/common/output-schemas/review-classifier.md +47 -0
  31. package/prompts/contexts/adapters/plannotator-objective.md +8 -1
  32. package/prompts/contexts/adapters/plannotator-plan.md +6 -1
  33. package/prompts/contexts/gist-authoring.md +22 -0
  34. package/prompts/stages/address/action.md +15 -4
  35. package/prompts/stages/address/preview.md +14 -3
  36. package/prompts/stages/conflict-resolution.md +1 -1
  37. package/prompts/stages/gist-author/seed.md +10 -0
  38. package/prompts/stages/gist-save.md +9 -0
  39. package/prompts/stages/learn-orchestrate.md +7 -5
  40. package/prompts/stages/objective-plan/guidance.md +12 -1
  41. package/prompts/stages/objective-plan/seed.md +12 -1
  42. package/prompts/stages/pr-review-browser/active.md +11 -3
  43. package/prompts/stages/pr-review-browser/foreign.md +11 -3
  44. package/prompts/stages/pr-review-dynamic.md +7 -0
  45. package/prompts/stages/pr-review-terminal/active.md +11 -3
  46. package/prompts/stages/pr-review-terminal/foreign.md +11 -3
  47. package/prompts/stages/pr-review.md +7 -6
  48. package/shared/bindings.yaml +6 -0
  49. package/shared/contracts.md +221 -45
  50. package/shared/registry.yaml +31 -1
@@ -0,0 +1,265 @@
1
+ // The `gist_draft` file tool: the third member of the draft carve-out family
2
+ // (planDraft.ts, objectiveDraft.ts) — the gist twin, minus the roadmap.
3
+ //
4
+ // Carve-out doctrine: the tool takes NO path/name parameter — the artifact name is the fixed
5
+ // constant `GIST_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-gist 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 — `gist_save`/`/gist-save` still
11
+ // persist the gist to the issue backend.
12
+ //
13
+ // Format doctrine: JSON is the storage/transport format, NEVER the human review surface. The
14
+ // artifact carries `{schema_version, title?, scope?, prose}` — deliberately light: a gist is a
15
+ // problem-space statement of intent with no structured roadmap (contracts.md §8.41). The review
16
+ // surface reads the draft via `readGistDraft` (over `readSessionArtifact` — digest-validated,
17
+ // fail-open) and renders markdown via `renderGistDraft` (title + a `Scope:` line + the prose) —
18
+ // never raw JSON.
19
+ //
20
+ // Vocabulary ownership: this module owns the shared draft/save param vocabulary
21
+ // (`GistSaveParams`, `decodeGistSaveParams`, `GIST_SCOPES`) — gistDraft is the LEAF (mirroring
22
+ // planDraft←planSave's direction); gistSave.ts consumes it, so it may value-import
23
+ // `readGistDraft` cycle-free for the approval→save orchestration.
24
+ //
25
+ // Imports stay node builtins + sibling seams (sessionData.ts, result.ts) so the module loads
26
+ // under `node --test`; no manual `scratch`/`runs` path segments (cacheGuard.test.ts).
27
+
28
+ import { relative } from "node:path";
29
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { failFor, ok, type Result } from "../substrate/result.ts";
31
+ import {
32
+ activeSessionRunId,
33
+ digestSessionData,
34
+ readSessionArtifact,
35
+ type SessionDataCtx,
36
+ writeSessionArtifact,
37
+ } from "../substrate/sessionData.ts";
38
+ import { paramsOf, stringParam } from "../substrate/toolParams.ts";
39
+ import type { EntrySink } from "../substrate/workflowState.ts";
40
+ import type { ReportTarget } from "../surfaces/report.ts";
41
+
42
+ /** The gist consumption tiers (`scope` — contracts.md §8.41). */
43
+ export const GIST_SCOPES = ["plan", "objective"] as const;
44
+
45
+ export type GistScope = (typeof GIST_SCOPES)[number];
46
+
47
+ /** The decoded `gist_save` tool params (shared with `gist_draft`). */
48
+ export interface GistSaveParams {
49
+ prose: string;
50
+ title?: string;
51
+ scope?: GistScope;
52
+ }
53
+
54
+ /**
55
+ * Decode unknown `gist_save` tool-call params (the tool-boundary seam). `prose` absent decodes
56
+ * to `""` (so `saveGist`'s "no gist prose to save" `invalid_input` arm keeps owning that
57
+ * message) but present-but-mistyped → null (strict-fail); a present `scope` outside the enum is
58
+ * likewise a strict-fail (the schema already declares the enum — a bad value means a malformed
59
+ * call, never a silent default).
60
+ */
61
+ export function decodeGistSaveParams(params: unknown): GistSaveParams | null {
62
+ const p = paramsOf(params);
63
+ if (p === null) return null;
64
+ const prose = stringParam(p, "prose");
65
+ const title = stringParam(p, "title");
66
+ const scope = stringParam(p, "scope");
67
+ if (prose === null || title === null || scope === null) return null;
68
+ if (scope !== undefined && !(GIST_SCOPES as readonly string[]).includes(scope)) return null;
69
+ return { prose: prose ?? "", title, scope: scope as GistScope | undefined };
70
+ }
71
+
72
+ /** The fixed working-gist artifact name (one JSON file: the prose + the optional scope hint). */
73
+ export const GIST_DRAFT_ARTIFACT = "gist-draft.json";
74
+
75
+ /** The ok-arm details — provenance-consistent with the recorded `session_artifacts` pointer. */
76
+ export interface GistDraftOk {
77
+ name: string;
78
+ path: string;
79
+ digest: string;
80
+ bytes: number;
81
+ run_id: string;
82
+ }
83
+
84
+ export type GistDraftResult = Result<GistDraftOk>;
85
+
86
+ /**
87
+ * The core both the tool handler and tests call: serialize the working gist (prose + the
88
+ * optional title/scope) as one JSON artifact and write it through the accessor seam (file +
89
+ * `session_artifacts` provenance pointer). Soft result, never throws — failure taxonomy: empty
90
+ * prose → `invalid_input`; no session run_id → `no_run_id`; file-or-pointer write failure →
91
+ * `write_failed` (the seam already warned).
92
+ */
93
+ export function writeGistDraft(
94
+ sink: EntrySink,
95
+ ctx: SessionDataCtx & ReportTarget,
96
+ opts: { prose: string; title?: string; scope?: GistScope },
97
+ ): GistDraftResult {
98
+ const fail = failFor(ctx, "gist-draft");
99
+
100
+ if (!opts.prose.trim()) {
101
+ return fail("no gist prose to write (pass the full working draft)", "invalid_input");
102
+ }
103
+
104
+ const runId = activeSessionRunId(ctx);
105
+ if (runId === null) {
106
+ return fail("session has no run_id — cannot write the gist-draft artifact", "no_run_id");
107
+ }
108
+
109
+ // Deterministic key order via the explicit literal; `title`/`scope` are omitted when blank.
110
+ const title = opts.title?.trim();
111
+ const payload = {
112
+ schema_version: 1,
113
+ ...(title ? { title } : {}),
114
+ ...(opts.scope ? { scope: opts.scope } : {}),
115
+ prose: opts.prose,
116
+ };
117
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
118
+
119
+ const written = writeSessionArtifact(sink, ctx, GIST_DRAFT_ARTIFACT, content);
120
+ if (written === null) {
121
+ return fail(
122
+ `could not write the ${GIST_DRAFT_ARTIFACT} artifact (see warnings)`,
123
+ "write_failed",
124
+ );
125
+ }
126
+
127
+ // Derive digest/relative path consistently with the pointer the seam recorded.
128
+ const digest = digestSessionData(content);
129
+ const relPath = relative(ctx.cwd, written);
130
+ return ok(`Gist draft written → ${relPath} (${digest})`, {
131
+ name: GIST_DRAFT_ARTIFACT,
132
+ path: relPath,
133
+ digest,
134
+ bytes: Buffer.byteLength(content, "utf8"),
135
+ run_id: runId,
136
+ });
137
+ }
138
+
139
+ // ------------------------------------------------------------------- the reader + the renderer
140
+
141
+ /** The validated working-gist draft shape consumers receive from `readGistDraft`. */
142
+ export interface GistDraft {
143
+ title?: string;
144
+ scope?: GistScope;
145
+ prose: string;
146
+ }
147
+
148
+ /**
149
+ * Read + validate the working-gist draft artifact. Fail-open `null` everywhere (mirroring
150
+ * `readSessionArtifact`'s loud tier): no pointer/file/digest → `null` (the seam already spoke);
151
+ * malformed JSON, a non-object payload, an unsupported `schema_version`, or blank prose → a
152
+ * stderr warning + `null`. `title` is kept only when a non-blank string; `scope` only when a
153
+ * member of the enum (an unknown scope degrades to absent, never poisons the draft). Never
154
+ * throws.
155
+ */
156
+ export function readGistDraft(ctx: SessionDataCtx): GistDraft | null {
157
+ const artifact = readSessionArtifact(ctx, GIST_DRAFT_ARTIFACT);
158
+ if (artifact === null) return null;
159
+
160
+ const refuse = (why: string): null => {
161
+ console.error(`perk: warning: ${GIST_DRAFT_ARTIFACT} ${why} — refusing the draft`);
162
+ return null;
163
+ };
164
+ let parsed: unknown;
165
+ try {
166
+ parsed = JSON.parse(artifact.content);
167
+ } catch {
168
+ return refuse("is not valid JSON");
169
+ }
170
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
171
+ return refuse("is not a JSON object");
172
+ }
173
+ const payload = parsed as Record<string, unknown>;
174
+ if (payload.schema_version !== 1) {
175
+ return refuse(`has an unsupported schema_version (${JSON.stringify(payload.schema_version)})`);
176
+ }
177
+ const prose = payload.prose;
178
+ if (typeof prose !== "string" || !prose.trim()) {
179
+ return refuse("has no prose");
180
+ }
181
+ const title =
182
+ typeof payload.title === "string" && payload.title.trim() ? payload.title : undefined;
183
+ const scope =
184
+ typeof payload.scope === "string" && (GIST_SCOPES as readonly string[]).includes(payload.scope)
185
+ ? (payload.scope as GistScope)
186
+ : undefined;
187
+ return {
188
+ ...(title !== undefined ? { title } : {}),
189
+ ...(scope !== undefined ? { scope } : {}),
190
+ prose,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Render the draft as the markdown review surface (JSON is storage/transport only — contracts
196
+ * §8.1): the optional `# title` heading, a `Scope:` line when the hint is set, and the prose
197
+ * verbatim. Pure; never throws.
198
+ */
199
+ export function renderGistDraft(draft: GistDraft): string {
200
+ let out = "";
201
+ if (draft.title) out += `# ${draft.title}\n\n`;
202
+ if (draft.scope) out += `Scope: ${draft.scope}\n\n`;
203
+ return out + draft.prose;
204
+ }
205
+
206
+ const TOOL_GUIDELINES = [
207
+ "Call gist_draft to persist the current working gist as you author or revise it; pass the FULL prose each time (it rewrites the whole draft).",
208
+ "gist_draft never saves to the issue backend and never ends the turn — gist_save//gist-save remain the canonical save surface.",
209
+ "Pass gist_draft's `scope` only once the consumption tier is settled: `plan` for plan-sized intent, `objective` for objective-sized intent.",
210
+ ];
211
+
212
+ /** Register the `gist_draft` tool (the carve-out producer; interior-only). */
213
+ export function registerGistDraft(pi: ExtensionAPI): void {
214
+ pi.registerTool({
215
+ name: "gist_draft",
216
+ label: "Gist draft",
217
+ description:
218
+ "Write (or overwrite) the working gist draft — the statement-of-intent prose + an " +
219
+ "optional scope hint — to the session data dir and record its provenance pointer. The " +
220
+ "only sanctioned write surface while read-only. NOT a save — gist_save//gist-save still " +
221
+ "persist the gist to the issue backend.",
222
+ promptSnippet:
223
+ "Persist the working gist draft (statement-of-intent prose) to the session data dir (full rewrite)",
224
+ promptGuidelines: TOOL_GUIDELINES,
225
+ executionMode: "sequential",
226
+ parameters: {
227
+ type: "object",
228
+ additionalProperties: false,
229
+ required: ["prose"],
230
+ properties: {
231
+ prose: {
232
+ type: "string",
233
+ description:
234
+ "The gist prose (the problem-space intent: what we want, why it matters, what " +
235
+ "bounds it — no implementation steps).",
236
+ },
237
+ title: {
238
+ type: "string",
239
+ description: "Optional gist title (defaults to the prose's first heading).",
240
+ },
241
+ scope: {
242
+ type: "string",
243
+ enum: [...GIST_SCOPES],
244
+ description:
245
+ "Optional consumption tier: plan (plan-sized intent) or objective (objective-sized).",
246
+ },
247
+ },
248
+ },
249
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
250
+ // The shared param contract: the same decode as `gist_save`, so the two cannot drift.
251
+ const decoded = decodeGistSaveParams(params);
252
+ if (decoded === null) {
253
+ return failFor(
254
+ ctx,
255
+ "gist-draft",
256
+ "gist_draft",
257
+ )(
258
+ "gist_draft needs { prose: string, scope?: plan|objective } per the tool schema",
259
+ "bad_input",
260
+ );
261
+ }
262
+ return writeGistDraft(pi, ctx, decoded);
263
+ },
264
+ });
265
+ }
@@ -0,0 +1,251 @@
1
+ // The warm `gist_save` door, the gist mirror of objectiveSave.ts. The in-session twin of the
2
+ // Python cold door (`perk gist create`): a deterministic, terminating tool + command that WRAP
3
+ // the existing storage — they do NOT reimplement the backend write. `saveGist()` delegates to
4
+ // `perk gist create --json` via the shared cold-door client (`runColdDoor`, the prose rides the
5
+ // run-scratch stdin channel). Unlike the plan/objective twins there is NO session linkage after
6
+ // the save — nothing consumes a gist in-session (adoption happens later via the cold doors), so
7
+ // a successful save just relays the envelope's id/url/consumption story.
8
+ //
9
+ // APPROVAL→SAVE ORCHESTRATION (mirroring objectiveSave.ts's `objectiveApprovalSave`). The
10
+ // exported `gistApprovalSave` seam is the shared APPROVED-review → save flow: re-read the
11
+ // artifact (`readGistDraft` — never the rendered markdown, never the transcript) → `saveGist` →
12
+ // D1a gate exit on a successful save (snapshot `gating.isActive()` BEFORE the save; a failed
13
+ // save leaves the gate ON). `plan_review`'s gist arm (planReview.ts) wires its APPROVED outcome
14
+ // into it; the `/gist-save` command is the artifact-first MANUAL FAILSAFE invocation of the same
15
+ // seam, keeping the legacy drive-the-session behavior as the no-draft fallback (gists have no
16
+ // 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 { registerPerkCommand } from "../substrate/command.ts";
28
+ import { render } from "../substrate/prompts.ts";
29
+ import { failFor, ok, type Result } from "../substrate/result.ts";
30
+ import type { ToolGating } from "../substrate/toolGating.ts";
31
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
32
+ import { report, type Severity } from "../surfaces/report.ts";
33
+ import { decodeGistSaveParams, GIST_SCOPES, type GistScope, readGistDraft } from "./gistDraft.ts";
34
+
35
+ /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
36
+ export interface GistSaveOk {
37
+ /** `id` is the opaque string gist id (GitHub "7", Linear "ENG-7"/project id) — §8.21. */
38
+ gist: { id: string; url: string };
39
+ scope: string | null;
40
+ existed: boolean | null;
41
+ }
42
+
43
+ export type GistSaveResult = Result<GistSaveOk>;
44
+
45
+ /** The decoded `perk gist create --json` payload slice the warm door consumes. */
46
+ interface GistCreatePayload {
47
+ gist: { id: string; url: string; existed: boolean | undefined };
48
+ scope: string | undefined;
49
+ }
50
+
51
+ /** Narrow the `perk gist create --json` success payload; strict on `gist`. */
52
+ function decodeGistCreate(payload: ColdJson): GistCreatePayload | null {
53
+ const gist = objectField(payload, "gist");
54
+ if (gist === undefined) return null;
55
+ const id = stringField(gist, "id");
56
+ const url = stringField(gist, "url");
57
+ if (id === undefined || url === undefined) return null;
58
+ return {
59
+ gist: { id, url, existed: booleanField(gist, "existed") },
60
+ scope: stringField(payload, "scope"),
61
+ };
62
+ }
63
+
64
+ /** The consumption pointer relayed after a save — the adoption door matching the saved scope. */
65
+ function consumptionHint(id: string, scope: string | undefined): string {
66
+ return scope === "objective"
67
+ ? `Consume with: perk objective author --from ${id}`
68
+ : `Consume with: perk plan from ${id}`;
69
+ }
70
+
71
+ /**
72
+ * The single save implementation both surfaces call. Delegates the backend write to the Python
73
+ * cold door (which owns scope resolution: an explicit `scope` wins, else the launch handoff's
74
+ * pre-seeded `gist_scope`, else `plan`). Returns a soft result (never throws); failures set
75
+ * `details.ok = false`. No session linkage on success — nothing consumes a gist in-session.
76
+ */
77
+ export async function saveGist(
78
+ pi: ExtensionAPI,
79
+ ctx: ExtensionContext,
80
+ opts: { prose: string; title?: string; scope?: GistScope },
81
+ ): Promise<GistSaveResult> {
82
+ const fail = failFor(ctx, "gist-save");
83
+
84
+ const prose = opts.prose.trim();
85
+ if (!prose) return fail("no gist prose to save (draft the gist first)", "invalid_input");
86
+ if (opts.scope !== undefined && !(GIST_SCOPES as readonly string[]).includes(opts.scope)) {
87
+ return fail("scope must be plan or objective", "invalid_input");
88
+ }
89
+
90
+ const runId = rebuildWorkflowState(branchOf(ctx)).run_id ?? "";
91
+
92
+ const args = ["gist", "create", "--json"];
93
+ if (opts.title) args.push("--title", opts.title);
94
+ if (opts.scope) args.push("--scope", opts.scope);
95
+ if (runId) args.push("--run-id", runId);
96
+ const r = await runColdDoor<GistCreatePayload>(pi, ctx, args, {
97
+ label: "perk gist create",
98
+ decode: decodeGistCreate,
99
+ stdin: { flag: "--body", content: prose, filename: "gist.md" },
100
+ });
101
+ if (!r.ok) return fail(r.message, r.errorType);
102
+
103
+ const gist = r.data.gist;
104
+ const verb = gist.existed ? "Found existing" : "Saved";
105
+ return ok(
106
+ `${verb} gist ${gist.id} → ${gist.url}\n${consumptionHint(gist.id, r.data.scope)}`,
107
+ {
108
+ gist: { id: gist.id, url: gist.url },
109
+ scope: r.data.scope ?? null,
110
+ existed: gist.existed ?? null,
111
+ },
112
+ { terminate: true },
113
+ );
114
+ }
115
+
116
+ /** The approval→save orchestration outcome (the gist `ApprovalSaveOutcome`). */
117
+ export type GistApprovalSaveOutcome =
118
+ | { status: "no-draft" }
119
+ | { status: "saved" | "save-failed"; result: GistSaveResult; gateExited: boolean };
120
+
121
+ /**
122
+ * The shared approval→save orchestration seam (the gist sibling of objectiveSave.ts's
123
+ * `objectiveApprovalSave`): an APPROVED gist review (`plan_review`'s gist arm) and the manual
124
+ * `/gist-save` failsafe both run THIS. Flow: re-read the draft artifact at save time
125
+ * (`readGistDraft` — never the rendered markdown, never in-hand bytes) → `saveGist` → gate exit
126
+ * on a successful save while read-only (the D1a pattern: snapshot `gating.isActive()` before the
127
+ * save; a failed save leaves the gate ON). No draft → `no-draft` (nothing saved, the gate
128
+ * untouched); callers render their own fallback. Title/scope precedence: explicit opts win; else
129
+ * the draft's; else the cold door derives/defaults. The returned result keeps `saveGist`'s
130
+ * `terminate: true` for tool-path callers.
131
+ */
132
+ export async function gistApprovalSave(
133
+ pi: ExtensionAPI,
134
+ ctx: ExtensionContext,
135
+ gating: ToolGating,
136
+ opts: { title?: string } = {},
137
+ ): Promise<GistApprovalSaveOutcome> {
138
+ const draft = readGistDraft(ctx);
139
+ if (draft === null) return { status: "no-draft" };
140
+ // D1a: snapshot the gate BEFORE the save; on success, exit it so save marks the read-only →
141
+ // read-write boundary in one gesture. A failed save leaves the gate on.
142
+ const wasReadOnly = gating.isActive();
143
+ const result = await saveGist(pi, ctx, {
144
+ prose: draft.prose,
145
+ title: opts.title ?? draft.title,
146
+ scope: draft.scope,
147
+ });
148
+ let gateExited = false;
149
+ if (result.details.ok && wasReadOnly) {
150
+ gating.exit(ctx);
151
+ gateExited = true;
152
+ }
153
+ return { status: result.details.ok ? "saved" : "save-failed", result, gateExited };
154
+ }
155
+
156
+ const TOOL_GUIDELINES = [
157
+ "Use gist_save only after the gist says what it means; it creates the tracked gist in the issue backend and ends the turn.",
158
+ "Pass gist_save the statement-of-intent PROSE in `prose` — problem-space only, no implementation steps or roadmap.",
159
+ "Pass gist_save's `scope` only once the consumption tier is settled (plan or objective); omit it to keep the pre-seeded/default scope.",
160
+ ];
161
+
162
+ /**
163
+ * The seed guidance the warm `/gist-save` injects to drive the save (the perk-gist-author skill
164
+ * pointer rides the skill-binding suffix — not hardcoded here). Pure + exported for offline
165
+ * tests.
166
+ */
167
+ export function gistSaveGuidance(title?: string): string {
168
+ const named = title?.trim() || "";
169
+ return render("stages/gist-save.md", { title: named });
170
+ }
171
+
172
+ /** Register the warm door: the `gist_save` tool (canonical) + the `/gist-save` twin. */
173
+ export function registerGistSave(pi: ExtensionAPI, gating: ToolGating): void {
174
+ pi.registerTool({
175
+ name: "gist_save",
176
+ label: "Save gist",
177
+ description:
178
+ "Persist a drafted gist (a statement of intent) to the issue backend as a tracked " +
179
+ "perk:gist. Terminating: ends the turn on save. Call only when the gist says what it " +
180
+ "means.",
181
+ promptSnippet: "Save the converged gist to the issue backend (terminates the turn)",
182
+ promptGuidelines: TOOL_GUIDELINES,
183
+ executionMode: "sequential",
184
+ parameters: {
185
+ type: "object",
186
+ additionalProperties: false,
187
+ required: ["prose"],
188
+ properties: {
189
+ prose: {
190
+ type: "string",
191
+ description:
192
+ "The gist prose (the problem-space intent: what we want, why it matters, what " +
193
+ "bounds it — no implementation steps).",
194
+ },
195
+ title: {
196
+ type: "string",
197
+ description: "Optional gist title (defaults to the prose's first heading).",
198
+ },
199
+ scope: {
200
+ type: "string",
201
+ enum: [...GIST_SCOPES],
202
+ description:
203
+ "Optional consumption tier: plan (plan-sized intent) or objective (objective-sized).",
204
+ },
205
+ },
206
+ },
207
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
208
+ const decoded = decodeGistSaveParams(params);
209
+ if (decoded === null) {
210
+ return failFor(
211
+ ctx,
212
+ "gist-save",
213
+ "gist_save",
214
+ )(
215
+ "gist_save needs { prose: string, scope?: plan|objective } per the tool schema",
216
+ "bad_input",
217
+ );
218
+ }
219
+ return saveGist(pi, ctx, decoded);
220
+ },
221
+ });
222
+
223
+ registerPerkCommand(pi, "gist-save", {
224
+ description:
225
+ "Save the working gist draft to the issue backend — the manual failsafe for the " +
226
+ "approval→save flow (artifact-first; drives the save only when no draft exists).",
227
+ handler: async (args, ctx) => {
228
+ const title = args.trim() || undefined;
229
+ // The artifact-first manual-failsafe invocation of the shared approval→save seam (the D1a
230
+ // gate exit lives in the seam). The legacy drive-the-session behavior is kept as the
231
+ // NO-DRAFT fallback — gists have no transcript scrape by design, so a draftless session
232
+ // still needs a working save path.
233
+ const outcome = await gistApprovalSave(pi, ctx, gating, { title });
234
+ if (outcome.status === "no-draft") {
235
+ // Exit the read-only gate so the gist_save tool (excluded from READ_ONLY_TOOLS) becomes
236
+ // reachable on the driven turn, then drive the turn (mirrors /objective-save).
237
+ if (gating.isActive()) gating.exit(ctx);
238
+ report(ctx, "gist-save", "info", "handing the save to the session");
239
+ // The perk-gist-author pointer rides the skill-binding suffix (D5) since a warm
240
+ // /gist-save outside a stage:gist-author session gets none from Mechanism A.
241
+ pi.sendUserMessage(gistSaveGuidance(title) + bindingSuffix(ctx.cwd, "stage:gist-author"));
242
+ return;
243
+ }
244
+ // Saved or save-failed: relay the save message (which carries the consumption hint).
245
+ const result = outcome.result;
246
+ const message = result.content[0]?.text ?? "gist-save done";
247
+ const severity: Severity = result.details.ok ? "info" : "error";
248
+ report(ctx, "gist-save", severity, message);
249
+ },
250
+ });
251
+ }
@@ -556,8 +556,9 @@ async function fetchObjectiveUrl(
556
556
  * perk-objective-plan skill pointer rides the skill-binding suffix — not hardcoded).
557
557
  * The loop is file-first (`plan_draft` → `plan_review` → approval-driven save); the node link
558
558
  * rides the `objective_node_claim` carrier recorded by the unconditional `planning` mark.
559
- * When `model` is set, the OPTIONAL `perk.objective-explorer` spawn carries an inline `model`
560
- * override ([models.subagents] objective-explorer); otherwise the agent's frontmatter default is used. */
559
+ * When `model` is set, the OPTIONAL `perk.objective-explorer` workflowScript call carries a
560
+ * workflow-level `model` default ([models.subagents] objective-explorer); otherwise the agent's
561
+ * frontmatter default is used. */
561
562
  export function factoryGuidance(
562
563
  objective: string,
563
564
  node: string | null,
@@ -46,6 +46,7 @@ import { report } from "../surfaces/report.ts";
46
46
  // `Key` via the surfaces re-export (keybinding vocabulary, not rich UI) — keeps pi-tui imports
47
47
  // structurally confined to the surfaces module (the surfacesGuard pi-tui import rule).
48
48
  import { Key } from "../surfaces/surfaces.ts";
49
+ import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
49
50
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
50
51
 
51
52
  /** The plan-authoring context customType (distinct from the gate's `perk:mode-context`). */
@@ -156,14 +157,16 @@ export function registerPlanMode(pi: ExtensionAPI, gating: ToolGating): void {
156
157
  });
157
158
  }
158
159
 
159
- // Inject the plan-authoring context while the read-only gate is active (display:false). The one
160
- // exception: an objective-author session is ALSO read-only, but objectiveAuthor.ts injects its
161
- // own authoring context there — so plan mode defers when the launched stage is objective-author
162
- // (the coupling break: plan-authoring context is no longer keyed off the bare read-only gate).
160
+ // Inject the plan-authoring context while the read-only gate is active (display:false). The
161
+ // exceptions: objective-author and gist-author sessions are ALSO read-only, but
162
+ // objectiveAuthor.ts / gistAuthor.ts inject their own authoring contexts there — so plan mode
163
+ // defers when the launched stage is either (the coupling break: plan-authoring context is no
164
+ // longer keyed off the bare read-only gate).
163
165
  pi.on("before_agent_start", async (_event, ctx) => {
164
166
  if (!gating.isActive()) return;
165
167
  const branch = branchOf(ctx);
166
- if (rebuildWorkflowState(branch).stage === OBJECTIVE_AUTHOR_STAGE) return;
168
+ const launchedStage = rebuildWorkflowState(branch).stage;
169
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === GIST_AUTHOR_STAGE) return;
167
170
  // Once-only: injected customs persist to the branch, so a live copy suppresses re-injection;
168
171
  // compaction dropping it makes the scan come up clean and the next turn re-injects.
169
172
  if (branchCarries(branch, PLAN_MARKER)) return;