@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
|
@@ -26,12 +26,23 @@
|
|
|
26
26
|
// persisted `perk:workflow-state.mode`, the gate's own state twin.
|
|
27
27
|
//
|
|
28
28
|
// EVENT ENVELOPE (pinned against `@plannotator/pi-extension@0.20.0`, `plannotator-events.ts` —
|
|
29
|
-
// verified unchanged through 0.
|
|
29
|
+
// verified unchanged through 0.26.1):
|
|
30
30
|
// request — pi.events.emit("plannotator:request", { requestId, action: "plan-review",
|
|
31
31
|
// payload: { planContent, origin? }, respond }) // respond = in-payload callback
|
|
32
32
|
// handshake — respond({ status: "handled", result: { status: "pending", reviewId } })
|
|
33
33
|
// | respond({ status: "unavailable", error? }) | respond({ status: "error", error })
|
|
34
34
|
// decision — pi.events.on("plannotator:review-result", { reviewId, approved, feedback?, ... })
|
|
35
|
+
//
|
|
36
|
+
// DIRECT EDITS FEEDBACK FORMAT (pinned against plannotator `packages/editor/directEdits.ts`,
|
|
37
|
+
// `buildDirectEditsSection` / `composeFeedbackWithDirectEdits`, at v0.26.1). The browser's
|
|
38
|
+
// direct-edit mode arrives as PROSE inside the existing `feedback` string, never a new envelope
|
|
39
|
+
// field: `# Direct Edits\n` + blank line + a one-sentence preamble (two wording variants — never
|
|
40
|
+
// couple to it) + blank line + a ```diff fence containing
|
|
41
|
+
// `createTwoFilesPatch('plan.md (original)', 'plan.md (edited)', base, edited, undefined,
|
|
42
|
+
// undefined, { context: 3 }).trimEnd()` against the exact bytes perk submitted. The section is
|
|
43
|
+
// composed FIRST; non-sentinel annotation feedback follows after `\n\n---\n\n`; edits-only
|
|
44
|
+
// feedback is just the section. `extractDirectEdits` below parses it strictly (fail-open — a
|
|
45
|
+
// null degrades to today's verbatim behavior).
|
|
35
46
|
|
|
36
47
|
import { randomUUID } from "node:crypto";
|
|
37
48
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -186,6 +197,58 @@ export function createPlannotatorBridge(bus: PlannotatorBus): {
|
|
|
186
197
|
return { review };
|
|
187
198
|
}
|
|
188
199
|
|
|
200
|
+
// ------------------------------------------------------------------ Direct Edits extraction
|
|
201
|
+
|
|
202
|
+
const DIRECT_EDITS_HEADING = "# Direct Edits";
|
|
203
|
+
const DIFF_FENCE_OPEN = "```diff\n";
|
|
204
|
+
const REMAINDER_SEPARATOR = "\n\n---\n\n";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Whether `feedback` OPENS with the Direct Edits heading (plan-review feedback composes the
|
|
208
|
+
* section first — a heading anywhere else is quoted prose, not a section). Callers pair this
|
|
209
|
+
* with `extractDirectEdits`: heading present but extraction null means the section was seen but
|
|
210
|
+
* could not be honored (the fail-open ladder's loud-warning arm).
|
|
211
|
+
*/
|
|
212
|
+
export function hasDirectEditsHeading(feedback: string): boolean {
|
|
213
|
+
return feedback === DIRECT_EDITS_HEADING || feedback.startsWith(`${DIRECT_EDITS_HEADING}\n`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Strictly extract the Direct Edits unified diff from a plannotator review-result `feedback`
|
|
218
|
+
* string (the format pin lives in the module header). Returns the fence body as `diff` plus the
|
|
219
|
+
* annotation `remainder` after the section (one leading `\n\n---\n\n` separator stripped;
|
|
220
|
+
* `undefined` when blank). Null means "no extractable Direct Edits section" — both the
|
|
221
|
+
* no-section case AND a present-heading-but-unparseable body (callers distinguish the two via
|
|
222
|
+
* `hasDirectEditsHeading`). The preamble prose between the heading and the fence is skipped
|
|
223
|
+
* without inspecting its wording (plannotator ships two variants).
|
|
224
|
+
*/
|
|
225
|
+
export function extractDirectEdits(feedback: string): { diff: string; remainder?: string } | null {
|
|
226
|
+
if (!hasDirectEditsHeading(feedback)) return null;
|
|
227
|
+
const openIdx = feedback.indexOf(`\n${DIFF_FENCE_OPEN}`, DIRECT_EDITS_HEADING.length);
|
|
228
|
+
if (openIdx === -1) return null;
|
|
229
|
+
const bodyStart = openIdx + 1 + DIFF_FENCE_OPEN.length;
|
|
230
|
+
// The closing fence is the first line that is exactly ``` — unambiguous inside the body,
|
|
231
|
+
// because every diff body line carries a prefix char (` `/`-`/`+`/`\`/`@`), so no body line
|
|
232
|
+
// can start with a backtick.
|
|
233
|
+
let close = -1;
|
|
234
|
+
let searchFrom = bodyStart;
|
|
235
|
+
while (close === -1) {
|
|
236
|
+
const idx = feedback.indexOf("\n```", searchFrom);
|
|
237
|
+
if (idx === -1) return null;
|
|
238
|
+
const after = feedback[idx + 4];
|
|
239
|
+
if (after === undefined || after === "\n") {
|
|
240
|
+
close = idx;
|
|
241
|
+
} else {
|
|
242
|
+
searchFrom = idx + 4;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const diff = feedback.slice(bodyStart, close);
|
|
246
|
+
if (diff.trim() === "") return null;
|
|
247
|
+
let rest = feedback.slice(close + 4);
|
|
248
|
+
if (rest.startsWith(REMAINDER_SEPARATOR)) rest = rest.slice(REMAINDER_SEPARATOR.length);
|
|
249
|
+
return { diff, remainder: rest.trim() === "" ? undefined : rest };
|
|
250
|
+
}
|
|
251
|
+
|
|
189
252
|
// ----------------------------------------------------------------------------- registration
|
|
190
253
|
|
|
191
254
|
/**
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The warm `/commit-and-compact` door: commit the work so far, then compact the session.
|
|
2
|
+
//
|
|
3
|
+
// Human-only slash command (no model-facing tool twin, no cold door, no workflow-state field —
|
|
4
|
+
// warm-plane only). The commit half needs the model (real staging judgment + a real commit
|
|
5
|
+
// message), so the dirty arm DRIVES the session (`pi.sendUserMessage`, warm-door discipline);
|
|
6
|
+
// the compaction half is deterministic extension work keyed on `agent_settled` (the one-shot
|
|
7
|
+
// "the driven run fully settled" hook — `turn_end` would compact mid-run). Fail-safe posture:
|
|
8
|
+
// never compact when uncommitted work might exist — the undeterminable-git-state and no-commit
|
|
9
|
+
// arms skip compaction with a loud warning naming pi's builtin `/compact` escape hatch. Clean
|
|
10
|
+
// and read-only trees compact immediately (the commit half is vacuous there).
|
|
11
|
+
//
|
|
12
|
+
// The pending record is in-memory by design (lost on `/reload` — the user re-runs the command);
|
|
13
|
+
// re-invoking while a drive is in flight simply overwrites it.
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
17
|
+
import { registerPerkCommand } from "../substrate/command.ts";
|
|
18
|
+
import { commitsSince, headSha, worktreeDirty } from "../substrate/git.ts";
|
|
19
|
+
import { render } from "../substrate/prompts.ts";
|
|
20
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
21
|
+
import { report, type Severity } from "../surfaces/report.ts";
|
|
22
|
+
|
|
23
|
+
/** The driven-commit guidance (pure + exported for offline tests and the drive-coverage guard). */
|
|
24
|
+
export function commitAndCompactGuidance(): string {
|
|
25
|
+
return render("commit-and-compact.md", {});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compaction instructions for the arms with nothing to commit (read-only / clean tree). Inline,
|
|
30
|
+
* not a `prompts/` template — compaction `customInstructions` stay inline (the objective
|
|
31
|
+
* threshold-compaction precedent); only injected user-message prose goes to `prompts/`.
|
|
32
|
+
*/
|
|
33
|
+
export const DIRECT_COMPACT_INSTRUCTIONS =
|
|
34
|
+
"Preserve the current task's intent, progress so far, and the concrete next steps.";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compaction instructions for the committed arm: embed the `git log --oneline` listing of the
|
|
38
|
+
* new commit(s) so the summary references them. Pure + exported for offline tests.
|
|
39
|
+
*/
|
|
40
|
+
export function compactInstructions(commits: string | null): string {
|
|
41
|
+
return [
|
|
42
|
+
"The work completed so far was just committed:",
|
|
43
|
+
"",
|
|
44
|
+
commits ?? "(commit list unavailable)",
|
|
45
|
+
"",
|
|
46
|
+
"Preserve in the summary: the task being implemented and its current progress, what the new " +
|
|
47
|
+
"commit(s) contain, and the concrete next steps for the remaining work. The committed diff " +
|
|
48
|
+
"is recoverable via git, so prefer intent and next steps over restating the diff.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The one-shot record the dirty/drive arm leaves for the `agent_settled` handler. */
|
|
53
|
+
export interface PendingCompact {
|
|
54
|
+
cwd: string;
|
|
55
|
+
/** HEAD at invocation (null on an unborn HEAD) — the advance gate compares against this. */
|
|
56
|
+
headBefore: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The door's side-effect surface — `ExtensionContext`-backed in wiring, recorder fakes in tests. */
|
|
60
|
+
export interface CommitCompactIo {
|
|
61
|
+
report(severity: Severity, message: string): void;
|
|
62
|
+
/** Inject the driving user message (wiring appends the skill-binding suffix). */
|
|
63
|
+
send(guidance: string): void;
|
|
64
|
+
compact(customInstructions: string): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The invocation arms (gate → undeterminable → clean → dirty, in that order). Returns the
|
|
69
|
+
* pending record only on the dirty/drive arm — every other arm resolves immediately.
|
|
70
|
+
*/
|
|
71
|
+
export function startCommitAndCompact(
|
|
72
|
+
cwd: string,
|
|
73
|
+
gateActive: boolean,
|
|
74
|
+
io: CommitCompactIo,
|
|
75
|
+
): PendingCompact | null {
|
|
76
|
+
if (gateActive) {
|
|
77
|
+
io.report("info", "read-only session — nothing to commit; compacting…");
|
|
78
|
+
io.compact(DIRECT_COMPACT_INSTRUCTIONS);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const dirty = worktreeDirty(cwd);
|
|
82
|
+
if (dirty === null) {
|
|
83
|
+
// Fail-safe: never compact when uncommitted work might exist.
|
|
84
|
+
io.report(
|
|
85
|
+
"warning",
|
|
86
|
+
"cannot determine the git worktree state — compaction skipped; run /compact to compact anyway.",
|
|
87
|
+
);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!dirty) {
|
|
91
|
+
io.report("info", "worktree clean — nothing to commit; compacting…");
|
|
92
|
+
io.compact(DIRECT_COMPACT_INSTRUCTIONS);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
io.report("info", "driving a commit of the work completed so far…");
|
|
96
|
+
const headBefore = headSha(cwd);
|
|
97
|
+
io.send(commitAndCompactGuidance());
|
|
98
|
+
return { cwd, headBefore };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The settle arms: compact only when HEAD actually advanced past the invocation-time sha;
|
|
103
|
+
* otherwise (model declined / commit failed / HEAD unreadable) warn and skip — same fail-safe.
|
|
104
|
+
*/
|
|
105
|
+
export function settleCommitAndCompact(pending: PendingCompact, io: CommitCompactIo): void {
|
|
106
|
+
const headNow = headSha(pending.cwd);
|
|
107
|
+
if (headNow === null || headNow === pending.headBefore) {
|
|
108
|
+
io.report(
|
|
109
|
+
"warning",
|
|
110
|
+
"no commit was made — compaction skipped; run /compact to compact anyway.",
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
io.report("info", "committed — compacting the session…");
|
|
115
|
+
io.compact(compactInstructions(commitsSince(pending.cwd, pending.headBefore)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Register the `/commit-and-compact` command + its one-shot `agent_settled` consumer. */
|
|
119
|
+
export function registerCommitAndCompact(pi: ExtensionAPI, gating: ToolGating): void {
|
|
120
|
+
let pending: PendingCompact | null = null;
|
|
121
|
+
|
|
122
|
+
const ioFor = (ctx: ExtensionContext): CommitCompactIo => ({
|
|
123
|
+
report: (severity, message) => {
|
|
124
|
+
report(ctx, "commit-and-compact", severity, message);
|
|
125
|
+
},
|
|
126
|
+
send: (guidance) => {
|
|
127
|
+
// The trigger lets repos bind a skill via `[[bindings]]`; drive unconditionally — report()
|
|
128
|
+
// already carries the headless stderr fallback.
|
|
129
|
+
pi.sendUserMessage(guidance + bindingSuffix(ctx.cwd, "command:commit-and-compact"));
|
|
130
|
+
},
|
|
131
|
+
compact: (customInstructions) => {
|
|
132
|
+
// No onComplete: pi's own UI signals compaction, and callbacks must not touch a possibly-
|
|
133
|
+
// stale ctx after session replacement (the documented compaction race).
|
|
134
|
+
ctx.compact({
|
|
135
|
+
customInstructions,
|
|
136
|
+
onError: (error) => {
|
|
137
|
+
console.error(`perk: commit-and-compact — compaction failed — ${error}`);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
144
|
+
if (pending === null) return;
|
|
145
|
+
const record = pending;
|
|
146
|
+
pending = null; // consume-then-clear: the record is strictly one-shot
|
|
147
|
+
try {
|
|
148
|
+
settleCommitAndCompact(record, ioFor(ctx));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`perk: commit-and-compact — settle handling failed — ${error}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
registerPerkCommand(pi, "commit-and-compact", {
|
|
155
|
+
description:
|
|
156
|
+
"Commit the work completed so far (a driven model turn stages and writes the message), " +
|
|
157
|
+
"then compact the session. Clean or read-only sessions compact immediately; if no commit " +
|
|
158
|
+
"results, compaction is skipped.",
|
|
159
|
+
handler: async (_args, ctx) => {
|
|
160
|
+
pending = startCommitAndCompact(ctx.cwd, gating.isActive(), ioFor(ctx));
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Gist-authoring context injection (the gist mirror of objectiveAuthor.ts). A `perk gist
|
|
2
|
+
// author` cold launch opens a READ-ONLY session whose handoff `stage` is `gist-author`; this
|
|
3
|
+
// module injects the gist-authoring contract under its own `perk:gist-author-context` customType
|
|
4
|
+
// (once-only: branch-scan dedup'd on the marker), keyed off (read-only gate AND stage ===
|
|
5
|
+
// gist-author), optionally extended by the same `[workflow] plan_authoring` addendum the
|
|
6
|
+
// plan-authoring injection consumes (verbatim reuse, read per-event via loadPerkConfig).
|
|
7
|
+
// planMode.ts defers when the stage is gist-author, so exactly one authoring context is injected.
|
|
8
|
+
//
|
|
9
|
+
// The `gist_save` warm door (the tool + `/gist-save` command) lives in gistSave.ts, the mirror
|
|
10
|
+
// of objectiveSave.ts.
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
14
|
+
import { render } from "../substrate/prompts.ts";
|
|
15
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
16
|
+
import {
|
|
17
|
+
type BranchEntry,
|
|
18
|
+
branchCarries,
|
|
19
|
+
branchOf,
|
|
20
|
+
rebuildWorkflowState,
|
|
21
|
+
} from "../substrate/workflowState.ts";
|
|
22
|
+
|
|
23
|
+
/** The registry stage id of the gist-authoring session (shared with planMode's defer check). */
|
|
24
|
+
export const GIST_AUTHOR_STAGE = "gist-author";
|
|
25
|
+
|
|
26
|
+
/** The gist-authoring context customType (distinct from planMode's `perk:plan-context`). */
|
|
27
|
+
export const GIST_AUTHOR_CONTEXT_TYPE = "perk:gist-author-context";
|
|
28
|
+
const GIST_AUTHOR_MARKER = "[GIST AUTHORING]";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cooperative gather-then-author contract for gists. Prompting, NOT enforcement (the tool
|
|
32
|
+
* gate is the enforcement). Mirrors skills/perk-gist-author/SKILL.md: clarify the intent,
|
|
33
|
+
* explore lightly, keep the draft current with `gist_draft`, review via `plan_review`, approval
|
|
34
|
+
* auto-saves — no implementation strategy in the artifact.
|
|
35
|
+
*/
|
|
36
|
+
export const GIST_AUTHORING_CONTEXT = render("contexts/gist-authoring.md", {
|
|
37
|
+
marker: GIST_AUTHOR_MARKER,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Build the full gist-authoring injection, appending the project config addendum when present. */
|
|
41
|
+
export function gistAuthoringContextContent(cwd: string): string {
|
|
42
|
+
const addendum = loadPerkConfig(cwd).planAuthoring;
|
|
43
|
+
return addendum ? `${GIST_AUTHORING_CONTEXT}\n\n${addendum.trim()}` : GIST_AUTHORING_CONTEXT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether the current branch is a gist-author session (read-only gate AND stage match). */
|
|
47
|
+
function isGistAuthoring(gating: ToolGating, branch: readonly BranchEntry[]): boolean {
|
|
48
|
+
return gating.isActive() && rebuildWorkflowState(branch).stage === GIST_AUTHOR_STAGE;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register the gist-authoring context injection (display:false), the gist mirror of
|
|
53
|
+
* objectiveAuthor's injection. Inert outside a gist-author session; never throws.
|
|
54
|
+
*/
|
|
55
|
+
export function registerGistAuthor(pi: ExtensionAPI, gating: ToolGating): void {
|
|
56
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
57
|
+
const branch = branchOf(ctx);
|
|
58
|
+
if (!isGistAuthoring(gating, branch)) return;
|
|
59
|
+
// Once-only: injected customs persist to the branch, so a live copy suppresses re-injection;
|
|
60
|
+
// compaction dropping it makes the scan come up clean and the next turn re-injects.
|
|
61
|
+
if (branchCarries(branch, GIST_AUTHOR_MARKER)) return;
|
|
62
|
+
return {
|
|
63
|
+
message: {
|
|
64
|
+
customType: GIST_AUTHOR_CONTEXT_TYPE,
|
|
65
|
+
content: gistAuthoringContextContent(ctx.cwd),
|
|
66
|
+
display: false,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Strip the stale gist-authoring marker from context once the session is no longer authoring
|
|
72
|
+
// (gate off, or the stage moved on) so it never lingers — the same hygiene planMode applies.
|
|
73
|
+
pi.on("context", async (event, ctx) => {
|
|
74
|
+
const branch = branchOf(ctx);
|
|
75
|
+
if (isGistAuthoring(gating, branch)) return;
|
|
76
|
+
return {
|
|
77
|
+
messages: event.messages.filter((m) => {
|
|
78
|
+
const msg = m as { customType?: string; role?: string; content?: unknown };
|
|
79
|
+
if (msg.customType === GIST_AUTHOR_CONTEXT_TYPE) return false;
|
|
80
|
+
if (msg.role !== "user") return true;
|
|
81
|
+
const content = msg.content;
|
|
82
|
+
if (typeof content === "string") return !content.includes(GIST_AUTHOR_MARKER);
|
|
83
|
+
if (Array.isArray(content)) {
|
|
84
|
+
return !content.some(
|
|
85
|
+
(c) =>
|
|
86
|
+
(c as { type?: string; text?: string }).type === "text" &&
|
|
87
|
+
((c as { text?: string }).text ?? "").includes(GIST_AUTHOR_MARKER),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|