@mgiles/perk 2.1.0 → 2.2.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.
- package/extension/adapters/planAdapterPlannotator.ts +64 -1
- package/extension/doors/commitCompact.ts +163 -0
- package/extension/factories/gistAuthor.ts +94 -0
- package/extension/factories/gistDraft.ts +265 -0
- package/extension/factories/gistSave.ts +251 -0
- package/extension/factories/planMode.ts +8 -5
- package/extension/factories/planReview.ts +233 -12
- package/extension/index.ts +19 -0
- package/extension/substrate/git.ts +38 -0
- package/extension/substrate/toolGating.ts +8 -0
- package/extension/substrate/unifiedDiff.ts +224 -0
- package/package.json +2 -1
- package/prompts/_fixtures/live.yaml +13 -0
- package/prompts/commit-and-compact.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +8 -1
- package/prompts/contexts/adapters/plannotator-plan.md +6 -1
- package/prompts/contexts/gist-authoring.md +22 -0
- package/prompts/stages/gist-author/seed.md +10 -0
- package/prompts/stages/gist-save.md +9 -0
- package/shared/bindings.yaml +3 -0
- package/shared/contracts.md +119 -13
- package/shared/registry.yaml +31 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
160
|
-
//
|
|
161
|
-
// own authoring
|
|
162
|
-
// (the coupling break: plan-authoring context is no
|
|
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
|
-
|
|
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;
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
// `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
|
|
20
20
|
// `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
|
|
21
21
|
// node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
|
|
22
|
-
// feedback and directs a `plan_draft` rewrite + re-review.
|
|
22
|
+
// feedback and directs a `plan_draft` rewrite + re-review. Plannotator's browser "Direct Edits"
|
|
23
|
+
// (a `# Direct Edits` unified diff opening the feedback) are handled asymmetrically per arm: the
|
|
24
|
+
// PLAN arm mechanically applies an approved diff (strict apply → draft write-back → save the
|
|
25
|
+
// edited bytes; any failure falls open to the verbatim save + a loud warning); the OBJECTIVE arm
|
|
26
|
+
// cannot fold rendered-markdown edits into the structured draft, so an approve-with-edits SKIPS
|
|
27
|
+
// the save and returns one model-mediated revise round; DENY stays model-mediated on both arms. Strict on deny, FAIL-OPEN everywhere
|
|
23
28
|
// else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
|
|
24
29
|
// is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
|
|
25
30
|
// arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
|
|
@@ -40,6 +45,12 @@
|
|
|
40
45
|
// exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
|
|
41
46
|
// directs the human `/objective-save` failsafe.
|
|
42
47
|
//
|
|
48
|
+
// THE GIST ARM: a gist-author session (read-only, stage `gist-author`) routes through
|
|
49
|
+
// `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
|
|
50
|
+
// (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
|
|
51
|
+
// never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
|
|
52
|
+
// `reason: "no_gist_draft"`.
|
|
53
|
+
//
|
|
43
54
|
// INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
|
|
44
55
|
// restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
|
|
45
56
|
// through the `approvalSave` seam (Invariant 1: composes, never owns).
|
|
@@ -48,12 +59,18 @@ import { randomUUID } from "node:crypto";
|
|
|
48
59
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
49
60
|
import {
|
|
50
61
|
createPlannotatorBridge,
|
|
62
|
+
extractDirectEdits,
|
|
63
|
+
hasDirectEditsHeading,
|
|
51
64
|
isPlannotatorPlanSelected,
|
|
52
65
|
} from "../adapters/planAdapterPlannotator.ts";
|
|
53
66
|
import type { Result } from "../substrate/result.ts";
|
|
54
67
|
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
55
68
|
import { paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
69
|
+
import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
|
|
56
70
|
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
71
|
+
import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
|
|
72
|
+
import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
|
|
73
|
+
import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
|
|
57
74
|
import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
|
|
58
75
|
import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
|
|
59
76
|
import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
|
|
@@ -131,6 +148,17 @@ const OBJECTIVE_SUBJECT: ReviewSubject = {
|
|
|
131
148
|
noSourceError: "no objective draft resolved",
|
|
132
149
|
};
|
|
133
150
|
|
|
151
|
+
const GIST_SUBJECT: ReviewSubject = {
|
|
152
|
+
noun: "gist",
|
|
153
|
+
present: "the complete gist to the user",
|
|
154
|
+
presentUnavailable: "the complete gist to the user",
|
|
155
|
+
implementHereWhere: "on the gist path",
|
|
156
|
+
draftTool: "gist_draft",
|
|
157
|
+
failsafeCmd: "/gist-save",
|
|
158
|
+
detailsExtra: { subject: "gist" },
|
|
159
|
+
noSourceError: "no gist draft resolved",
|
|
160
|
+
};
|
|
161
|
+
|
|
134
162
|
const SKIP_TEXT =
|
|
135
163
|
"no interactive review surface available — present the complete plan to the user in your next message.";
|
|
136
164
|
|
|
@@ -244,16 +272,19 @@ type SubjectSaveOutcome =
|
|
|
244
272
|
* (propagating the seam's `terminate: true` intent); a failed save is non-terminating, leaves
|
|
245
273
|
* the gate read-only, and directs the human manual failsafe. Reviewer feedback is surfaced
|
|
246
274
|
* loudly as implementation guidance — the approved bytes were saved verbatim, never post-edited.
|
|
247
|
-
* The `paramMismatch`/`edited` opts are plan-arm-only (their literals name
|
|
248
|
-
* objective delegator never passes opts, so the suffixes render empty and
|
|
249
|
-
* its details.
|
|
275
|
+
* The `paramMismatch`/`edited`/`directEditsFailed` opts are plan-arm-only (their literals name
|
|
276
|
+
* "plan"/"draft"): the objective delegator never passes opts, so the suffixes render empty and
|
|
277
|
+
* `edited` never reaches its details. `directEditsFailed` (plannotator-only) flags that a Direct
|
|
278
|
+
* Edits section was seen but could not be honored — the saved arm gains a loud warning that the
|
|
279
|
+
* plan was saved WITHOUT the reviewer's edits, and details carry `direct_edits_applied: false`.
|
|
280
|
+
* The `no-source` arm is defensively unreachable (the reviewed source is always
|
|
250
281
|
* non-blank) but maps to the save-failed shape rather than throwing.
|
|
251
282
|
*/
|
|
252
283
|
function approvedSubjectSaveResult(
|
|
253
284
|
subject: ReviewSubject,
|
|
254
285
|
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
255
286
|
save: SubjectSaveOutcome,
|
|
256
|
-
opts?: { paramMismatch?: boolean; edited?: boolean },
|
|
287
|
+
opts?: { paramMismatch?: boolean; edited?: boolean; directEditsFailed?: boolean },
|
|
257
288
|
): ToolResult {
|
|
258
289
|
const feedback = outcome.feedback
|
|
259
290
|
? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
|
|
@@ -266,6 +297,7 @@ function approvedSubjectSaveResult(
|
|
|
266
297
|
feedback: outcome.feedback ?? null,
|
|
267
298
|
...subject.detailsExtra,
|
|
268
299
|
...(opts?.edited === true ? { edited: true } : {}),
|
|
300
|
+
...(opts?.directEditsFailed === true ? { direct_edits_applied: false } : {}),
|
|
269
301
|
};
|
|
270
302
|
if (save.status === "saved") {
|
|
271
303
|
const saveText = save.result.content[0]?.text ?? "";
|
|
@@ -275,11 +307,17 @@ function approvedSubjectSaveResult(
|
|
|
275
307
|
opts?.paramMismatch === true
|
|
276
308
|
? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
|
|
277
309
|
: "";
|
|
310
|
+
const editsWarning =
|
|
311
|
+
opts?.directEditsFailed === true
|
|
312
|
+
? "\n\n⚠ WARNING: the reviewer's Direct Edits could NOT be auto-applied — the plan was " +
|
|
313
|
+
"saved WITHOUT them. The diff remains in the reviewer feedback above; apply it to the " +
|
|
314
|
+
"plan issue manually or via a follow-up."
|
|
315
|
+
: "";
|
|
278
316
|
return {
|
|
279
317
|
content: [
|
|
280
318
|
{
|
|
281
319
|
type: "text",
|
|
282
|
-
text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
|
|
320
|
+
text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}${editsWarning}`,
|
|
283
321
|
},
|
|
284
322
|
],
|
|
285
323
|
// `ok` sits per-branch, NOT in `base` — `base` is spread into the fail branch too.
|
|
@@ -323,13 +361,15 @@ function approvedSubjectSaveResult(
|
|
|
323
361
|
/**
|
|
324
362
|
* Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
|
|
325
363
|
* (exported for the offline tests) — the plan flavor of `approvedSubjectSaveResult`. `edited`
|
|
326
|
-
*
|
|
327
|
-
* saved bytes carry them.
|
|
364
|
+
* flags that human edits were written back to the draft pre-verdict (the first-party editor, or
|
|
365
|
+
* the plannotator Direct Edits auto-apply), so the saved bytes carry them. `directEditsFailed`
|
|
366
|
+
* (plannotator-only, optional — absent keeps every existing call site byte-stable) flags a
|
|
367
|
+
* Direct Edits section that could not be honored: the plan saved verbatim, a loud warning added.
|
|
328
368
|
*/
|
|
329
369
|
export function approvedSaveResult(
|
|
330
370
|
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
331
371
|
save: ApprovalSaveOutcome,
|
|
332
|
-
opts: { paramMismatch: boolean; edited?: boolean },
|
|
372
|
+
opts: { paramMismatch: boolean; edited?: boolean; directEditsFailed?: boolean },
|
|
333
373
|
): ToolResult {
|
|
334
374
|
return approvedSubjectSaveResult(
|
|
335
375
|
PLAN_SUBJECT,
|
|
@@ -535,7 +575,12 @@ export function approvedObjectiveSaveResult(
|
|
|
535
575
|
* the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
|
|
536
576
|
* deny+feedback is the change channel). An APPROVED outcome wires into the
|
|
537
577
|
* `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
|
|
538
|
-
* exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`.
|
|
578
|
+
* exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`. ONE
|
|
579
|
+
* carve-out (plannotator only): an approval whose feedback opens a Direct Edits section SKIPS
|
|
580
|
+
* the save — rendered-markdown edits cannot be folded back into the structured draft
|
|
581
|
+
* mechanically — and returns a NON-terminating revise round with the gate untouched (fold the
|
|
582
|
+
* diff in via `objective_draft`, re-review to confirm); perk never saves an objective the
|
|
583
|
+
* reviewer explicitly edited away from.
|
|
539
584
|
*/
|
|
540
585
|
export async function executeObjectiveReview(
|
|
541
586
|
pi: ExtensionAPI,
|
|
@@ -576,6 +621,42 @@ export async function executeObjectiveReview(
|
|
|
576
621
|
let outcome: ReviewOutcome;
|
|
577
622
|
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
578
623
|
outcome = await bridge.review(rendered, sig);
|
|
624
|
+
// APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
|
|
625
|
+
// approved-save routing (the approved-first discipline): the save seam re-reads the
|
|
626
|
+
// STRUCTURED artifact, so rendered-markdown edits — roadmap-table rows included — cannot be
|
|
627
|
+
// folded back without model judgment. Skip the save, keep the gate read-only, and route ONE
|
|
628
|
+
// revise round: the model folds the diff into `objective_draft`, then re-reviews to confirm.
|
|
629
|
+
// The heading check suffices (extraction success is irrelevant here — the diff goes to the
|
|
630
|
+
// model verbatim either way).
|
|
631
|
+
if (
|
|
632
|
+
outcome.status === "completed" &&
|
|
633
|
+
outcome.approved &&
|
|
634
|
+
outcome.feedback !== undefined &&
|
|
635
|
+
hasDirectEditsHeading(outcome.feedback)
|
|
636
|
+
) {
|
|
637
|
+
return {
|
|
638
|
+
content: [
|
|
639
|
+
{
|
|
640
|
+
type: "text",
|
|
641
|
+
text:
|
|
642
|
+
"objective APPROVED with direct browser edits — these cannot be auto-applied to " +
|
|
643
|
+
"the structured draft, so nothing was saved. Fold the Direct Edits diff below into " +
|
|
644
|
+
"the working draft with objective_draft (prose hunks → the prose; roadmap-table " +
|
|
645
|
+
"hunks → the matching node fields), then call plan_review again to confirm.\n\n" +
|
|
646
|
+
`Reviewer feedback:\n${outcome.feedback}`,
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
details: {
|
|
650
|
+
ok: true,
|
|
651
|
+
status: "revise",
|
|
652
|
+
reason: "direct_edits",
|
|
653
|
+
approved: true,
|
|
654
|
+
feedback: outcome.feedback,
|
|
655
|
+
reviewId: outcome.reviewId,
|
|
656
|
+
subject: "objective",
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
579
660
|
} else {
|
|
580
661
|
const fp = await runFirstPartyReview({
|
|
581
662
|
ui: ctx.ui,
|
|
@@ -599,6 +680,111 @@ export async function executeObjectiveReview(
|
|
|
599
680
|
return objectiveReviewOutcomeResult(outcome);
|
|
600
681
|
}
|
|
601
682
|
|
|
683
|
+
// ------------------------------------------------------------------------ the gist review arm
|
|
684
|
+
|
|
685
|
+
const GIST_REVIEW_EDITOR_TITLE =
|
|
686
|
+
"Gist review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
|
|
687
|
+
"Ctrl+G: $EDITOR";
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Map a non-approved gist review outcome into the model-facing tool result (exported for the
|
|
691
|
+
* offline tests) — the gist-flavored sibling of `objectiveReviewOutcomeResult`, delegating to
|
|
692
|
+
* `subjectReviewOutcomeResult` with `GIST_SUBJECT`. Every arm carries `details.subject: "gist"`;
|
|
693
|
+
* the texts redirect to `gist_draft` / `/gist-save`. The execute path routes approved outcomes
|
|
694
|
+
* to `approvedGistSaveResult` first, so `completed` renders DENIED here.
|
|
695
|
+
*/
|
|
696
|
+
export function gistReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
|
|
697
|
+
return subjectReviewOutcomeResult(GIST_SUBJECT, outcome);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Map an APPROVED gist review outcome + the `gistApprovalSave` outcome into the model-facing
|
|
702
|
+
* tool result (exported for the offline tests) — the gist sibling of
|
|
703
|
+
* `approvedObjectiveSaveResult`, delegating to `approvedSubjectSaveResult` with `GIST_SUBJECT`
|
|
704
|
+
* and no opts (the gist path reviews only the rendered draft, view-only — no
|
|
705
|
+
* `paramMismatch`/`edited`).
|
|
706
|
+
*/
|
|
707
|
+
export function approvedGistSaveResult(
|
|
708
|
+
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
709
|
+
save: GistApprovalSaveOutcome,
|
|
710
|
+
): ToolResult {
|
|
711
|
+
return approvedSubjectSaveResult(
|
|
712
|
+
GIST_SUBJECT,
|
|
713
|
+
outcome,
|
|
714
|
+
save.status === "no-draft" ? { status: "no-source" } : save,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The gist review arm, mirroring `executeObjectiveReview`'s shape with the rendered gist draft
|
|
720
|
+
* as the SOLE review source (never the `plan` param, never the transcript). First-party reviews
|
|
721
|
+
* run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
|
|
722
|
+
* implement-here verdict is never offered (a gist is not implementable — it has no strategy).
|
|
723
|
+
* An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
|
|
724
|
+
* → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
|
|
725
|
+
*/
|
|
726
|
+
export async function executeGistReview(
|
|
727
|
+
pi: ExtensionAPI,
|
|
728
|
+
ctx: ExtensionContext,
|
|
729
|
+
gating: ToolGating,
|
|
730
|
+
bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
|
|
731
|
+
signal?: AbortSignal,
|
|
732
|
+
): Promise<ToolResult> {
|
|
733
|
+
// 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
|
|
734
|
+
if (!ctx.hasUI) return skipResult();
|
|
735
|
+
// 2. The draft artifact is the sole review source — no draft → soft skip with the gist_draft
|
|
736
|
+
// redirect.
|
|
737
|
+
const draft = readGistDraft(ctx);
|
|
738
|
+
if (draft === null) {
|
|
739
|
+
return {
|
|
740
|
+
content: [
|
|
741
|
+
{
|
|
742
|
+
type: "text",
|
|
743
|
+
text:
|
|
744
|
+
"no gist draft to review — write the working gist with gist_draft (the " +
|
|
745
|
+
"statement-of-intent prose), then call plan_review again.",
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
details: {
|
|
749
|
+
ok: false,
|
|
750
|
+
error: "no gist draft to review — write it with gist_draft first",
|
|
751
|
+
error_type: "no_gist_draft",
|
|
752
|
+
status: "skipped",
|
|
753
|
+
reason: "no_gist_draft",
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
// 3. The reviewed bytes are the RENDERED markdown (title + scope + prose) — never raw JSON.
|
|
758
|
+
const rendered = renderGistDraft(draft);
|
|
759
|
+
// 4. Backend dispatch (mirrors the objective path): plannotator-selected → the bridge; ANY
|
|
760
|
+
// other selection → the first-party editor, view-only.
|
|
761
|
+
const sig = signal ?? ctx.signal;
|
|
762
|
+
let outcome: ReviewOutcome;
|
|
763
|
+
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
764
|
+
outcome = await bridge.review(rendered, sig);
|
|
765
|
+
} else {
|
|
766
|
+
const fp = await runFirstPartyReview({
|
|
767
|
+
ui: ctx.ui,
|
|
768
|
+
plan: rendered,
|
|
769
|
+
writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
|
|
770
|
+
signal: sig,
|
|
771
|
+
editorTitle: GIST_REVIEW_EDITOR_TITLE,
|
|
772
|
+
verdicts: verdictsFor(GIST_SUBJECT),
|
|
773
|
+
viewOnly: true,
|
|
774
|
+
});
|
|
775
|
+
outcome = fp.outcome;
|
|
776
|
+
}
|
|
777
|
+
// 5. An APPROVED decision (either backend) wires into the gistApprovalSave seam (the artifact
|
|
778
|
+
// is re-read at save time — never the rendered bytes; auto-save → D1a gate exit →
|
|
779
|
+
// terminating result); everything else maps via gistReviewOutcomeResult. Approved-first
|
|
780
|
+
// routing: gistReviewOutcomeResult's completed case renders DENIED.
|
|
781
|
+
if (outcome.status === "completed" && outcome.approved) {
|
|
782
|
+
const save = await gistApprovalSave(pi, ctx, gating);
|
|
783
|
+
return approvedGistSaveResult(outcome, save);
|
|
784
|
+
}
|
|
785
|
+
return gistReviewOutcomeResult(outcome);
|
|
786
|
+
}
|
|
787
|
+
|
|
602
788
|
// ------------------------------------------------------------------------- the execute core
|
|
603
789
|
|
|
604
790
|
/**
|
|
@@ -641,9 +827,14 @@ export async function executePlanReview(
|
|
|
641
827
|
}
|
|
642
828
|
// 1. Objective-author session → the objective review arm: the rendered
|
|
643
829
|
// objective draft is the sole review source; a well-typed `plan` param is ignored here.
|
|
644
|
-
|
|
830
|
+
// A gist-author session likewise routes to the gist arm (the rendered gist draft).
|
|
831
|
+
const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
|
|
832
|
+
if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
|
|
645
833
|
return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
|
|
646
834
|
}
|
|
835
|
+
if (launchedStage === GIST_AUTHOR_STAGE) {
|
|
836
|
+
return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
|
|
837
|
+
}
|
|
647
838
|
// 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
|
|
648
839
|
if (!ctx.hasUI) return skipResult();
|
|
649
840
|
// 3. File-first resolution: artifact → param, NEVER transcript — an approval
|
|
@@ -674,8 +865,34 @@ export async function executePlanReview(
|
|
|
674
865
|
let outcome: ReviewOutcome;
|
|
675
866
|
let reviewedPlan = src.plan;
|
|
676
867
|
let edited = false;
|
|
868
|
+
let directEditsFailed = false;
|
|
677
869
|
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
678
870
|
outcome = await bridge.review(src.plan, sig);
|
|
871
|
+
// APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
|
|
872
|
+
// reviewer's diff to the exact bytes reviewed, write it back to the draft (reviewed bytes ==
|
|
873
|
+
// artifact bytes == saved bytes — the first-party pre-verdict write-back, replayed here
|
|
874
|
+
// post-verdict because the bridge only reports the diff), and save the EDITED bytes. Every
|
|
875
|
+
// rung fails open to the verbatim path: no section → untouched; a heading that cannot be
|
|
876
|
+
// parsed / applied / written back → verbatim save + a loud warning (never save bytes the
|
|
877
|
+
// artifact doesn't carry). DENY stays model-mediated — the feedback (diff included) passes
|
|
878
|
+
// through for the plan_draft rewrite.
|
|
879
|
+
if (outcome.status === "completed" && outcome.approved && outcome.feedback !== undefined) {
|
|
880
|
+
const section = extractDirectEdits(outcome.feedback);
|
|
881
|
+
if (section !== null) {
|
|
882
|
+
const patched = applyUnifiedDiff(src.plan, section.diff);
|
|
883
|
+
if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
|
|
884
|
+
reviewedPlan = patched;
|
|
885
|
+
edited = true;
|
|
886
|
+
// The applied diff must NOT survive into the result as "apply these exact changes"
|
|
887
|
+
// guidance — only the annotation remainder (when any) stays reviewer feedback.
|
|
888
|
+
outcome = { ...outcome, feedback: section.remainder };
|
|
889
|
+
} else {
|
|
890
|
+
directEditsFailed = true;
|
|
891
|
+
}
|
|
892
|
+
} else if (hasDirectEditsHeading(outcome.feedback)) {
|
|
893
|
+
directEditsFailed = true;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
679
896
|
} else {
|
|
680
897
|
// The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
|
|
681
898
|
// objective-node planning session — a node-linked plan must save (the node advance and
|
|
@@ -705,7 +922,11 @@ export async function executePlanReview(
|
|
|
705
922
|
// gate exit → terminating result); everything else maps via reviewOutcomeResult.
|
|
706
923
|
if (outcome.status === "completed" && outcome.approved) {
|
|
707
924
|
const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
|
|
708
|
-
return approvedSaveResult(outcome, save, {
|
|
925
|
+
return approvedSaveResult(outcome, save, {
|
|
926
|
+
paramMismatch: src.paramMismatch,
|
|
927
|
+
edited,
|
|
928
|
+
directEditsFailed,
|
|
929
|
+
});
|
|
709
930
|
}
|
|
710
931
|
return reviewOutcomeResult(outcome);
|
|
711
932
|
}
|