@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.
- package/extension/adapters/planAdapterPlannotator.ts +64 -1
- package/extension/doors/address.ts +3 -3
- package/extension/doors/commitCompact.ts +163 -0
- package/extension/doors/learn.ts +219 -23
- package/extension/doors/prReview.ts +189 -18
- package/extension/doors/prReviewDynamic.ts +249 -0
- package/extension/doors/submit.ts +4 -3
- package/extension/factories/gistAuthor.ts +94 -0
- package/extension/factories/gistDraft.ts +265 -0
- package/extension/factories/gistSave.ts +251 -0
- package/extension/factories/objectivePlan.ts +3 -2
- package/extension/factories/planMode.ts +8 -5
- package/extension/factories/planReview.ts +233 -12
- package/extension/index.ts +26 -0
- package/extension/substrate/config.ts +8 -4
- package/extension/substrate/git.ts +38 -0
- package/extension/substrate/terminalLaunch.ts +1 -1
- package/extension/substrate/toolGating.ts +44 -3
- package/extension/substrate/unifiedDiff.ts +224 -0
- package/extension/waves/learnWave.ts +155 -0
- package/extension/waves/memoryAdapter.ts +126 -0
- package/extension/waves/prReviewDynamicWave.ts +466 -0
- package/extension/waves/prReviewWave.ts +229 -0
- package/extension/waves/reportWave.ts +449 -0
- package/extension/waves/rpcAdapter.ts +201 -0
- package/package.json +7 -1
- package/prompts/_fixtures/live.yaml +22 -11
- package/prompts/commit-and-compact.md +7 -0
- package/prompts/common/output-schemas/objective-explorer.md +36 -0
- package/prompts/common/output-schemas/review-classifier.md +47 -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/address/action.md +15 -4
- package/prompts/stages/address/preview.md +14 -3
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +10 -0
- package/prompts/stages/gist-save.md +9 -0
- package/prompts/stages/learn-orchestrate.md +7 -5
- package/prompts/stages/objective-plan/guidance.md +12 -1
- package/prompts/stages/objective-plan/seed.md +12 -1
- package/prompts/stages/pr-review-browser/active.md +11 -3
- package/prompts/stages/pr-review-browser/foreign.md +11 -3
- package/prompts/stages/pr-review-dynamic.md +7 -0
- package/prompts/stages/pr-review-terminal/active.md +11 -3
- package/prompts/stages/pr-review-terminal/foreign.md +11 -3
- package/prompts/stages/pr-review.md +7 -6
- package/shared/bindings.yaml +6 -0
- package/shared/contracts.md +221 -45
- 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`
|
|
560
|
-
*
|
|
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
|
|
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;
|