@mgiles/perk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The warm `/land` door. The in-session twin of the Python cold door
|
|
2
|
+
// (`perk pr land`): a terminating tool + command that DELEGATE the GitHub merge (mutations
|
|
3
|
+
// canonical in Python), then set the `pending-learn` marker for the in-session path (the worker
|
|
4
|
+
// sets it too on the cold path; the marker is an idempotent existence-semaphore). Never throws.
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { reconcileGuidance } from "../factories/objectivePlan.ts";
|
|
8
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
9
|
+
import { PENDING_LEARN, setMarker } from "../substrate/cache.ts";
|
|
10
|
+
import {
|
|
11
|
+
type ColdJson,
|
|
12
|
+
nullableStringField,
|
|
13
|
+
numberField,
|
|
14
|
+
objectField,
|
|
15
|
+
runColdDoor,
|
|
16
|
+
stringField,
|
|
17
|
+
} from "../substrate/coldDoor.ts";
|
|
18
|
+
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
19
|
+
import { report } from "../surfaces/report.ts";
|
|
20
|
+
|
|
21
|
+
// Learn-consume skip reasons that are ordinary, not failures: non-factory plans carry no
|
|
22
|
+
// `consumed_learn` (`no_consumed_learn`), and a dry run reports `dry_run`. Anything else surfaces.
|
|
23
|
+
const BENIGN_LEARN_SKIPS = new Set(["no_consumed_learn", "dry_run"]);
|
|
24
|
+
|
|
25
|
+
export interface ObjectiveLandUpdate {
|
|
26
|
+
/** Opaque string objective id (GitHub "5", Linear "ENG-5") — §8.21. */
|
|
27
|
+
id: string | null;
|
|
28
|
+
nodes_marked: string[];
|
|
29
|
+
skipped_reason: string | null;
|
|
30
|
+
closed: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LearnConsumeUpdate {
|
|
34
|
+
/** Opaque string learn-issue ids (§8.21). */
|
|
35
|
+
closed: string[];
|
|
36
|
+
skipped_reason: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
|
|
40
|
+
export interface LandOk {
|
|
41
|
+
pr: { number: number; state: string };
|
|
42
|
+
branch?: string;
|
|
43
|
+
/** Opaque string plan-issue id (§8.21). */
|
|
44
|
+
issue?: string;
|
|
45
|
+
pending_learn: boolean;
|
|
46
|
+
objective?: ObjectiveLandUpdate;
|
|
47
|
+
learn?: LearnConsumeUpdate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type LandResult = Result<LandOk>;
|
|
51
|
+
export type LandDetails = LandResult["details"];
|
|
52
|
+
|
|
53
|
+
/** The decoded `perk pr land --json` payload — `LandOk` minus the warm-door-owned `pending_learn`. */
|
|
54
|
+
interface LandPayload {
|
|
55
|
+
pr: { number: number; state: string };
|
|
56
|
+
branch?: string;
|
|
57
|
+
issue?: string;
|
|
58
|
+
objective?: ObjectiveLandUpdate;
|
|
59
|
+
learn?: LearnConsumeUpdate;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Validate the optional `objective` sub-object; malformed → undefined (advisory, never fatal). */
|
|
63
|
+
function decodeObjective(payload: ColdJson): ObjectiveLandUpdate | undefined {
|
|
64
|
+
const obj = objectField(payload, "objective");
|
|
65
|
+
if (obj === undefined) return undefined;
|
|
66
|
+
const id = obj.id;
|
|
67
|
+
if (typeof id !== "string" && id !== null) return undefined;
|
|
68
|
+
const nodesMarked = obj.nodes_marked;
|
|
69
|
+
if (!Array.isArray(nodesMarked) || !nodesMarked.every((n) => typeof n === "string")) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const skippedReason = nullableStringField(obj, "skipped_reason");
|
|
73
|
+
if (skippedReason === undefined && obj.skipped_reason !== undefined) return undefined;
|
|
74
|
+
// `closed` is an advisory display detail: decode leniently (missing/malformed → false) rather
|
|
75
|
+
// than dropping the whole sub-object.
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
nodes_marked: nodesMarked,
|
|
79
|
+
skipped_reason: skippedReason ?? null,
|
|
80
|
+
closed: obj.closed === true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validate the optional `learn` sub-object; malformed → undefined (advisory, never fatal). */
|
|
85
|
+
function decodeLearn(payload: ColdJson): LearnConsumeUpdate | undefined {
|
|
86
|
+
const learn = objectField(payload, "learn");
|
|
87
|
+
if (learn === undefined) return undefined;
|
|
88
|
+
const closed = learn.closed;
|
|
89
|
+
if (!Array.isArray(closed) || !closed.every((n) => typeof n === "string")) return undefined;
|
|
90
|
+
const skippedReason = nullableStringField(learn, "skipped_reason");
|
|
91
|
+
if (skippedReason === undefined && learn.skipped_reason !== undefined) return undefined;
|
|
92
|
+
return { closed, skipped_reason: skippedReason ?? null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Narrow the `perk pr land --json` success payload. Strict on `pr` (malformed → bad_output);
|
|
97
|
+
* the optional `objective`/`learn` sub-objects are validated but dropped when malformed — the
|
|
98
|
+
* merge already succeeded, so the success report must survive a malformed advisory field.
|
|
99
|
+
*/
|
|
100
|
+
function decodeLand(payload: ColdJson): LandPayload | null {
|
|
101
|
+
const pr = objectField(payload, "pr");
|
|
102
|
+
if (pr === undefined) return null;
|
|
103
|
+
const number = numberField(pr, "number");
|
|
104
|
+
const state = stringField(pr, "state");
|
|
105
|
+
if (number === undefined || state === undefined) return null;
|
|
106
|
+
return {
|
|
107
|
+
pr: { number, state },
|
|
108
|
+
branch: stringField(payload, "branch"),
|
|
109
|
+
issue: stringField(payload, "issue"),
|
|
110
|
+
objective: decodeObjective(payload),
|
|
111
|
+
learn: decodeLearn(payload),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The single land implementation both surfaces call. Delegates the merge to the Python cold door,
|
|
117
|
+
* then sets `pending-learn` (in-session path). Returns a soft result (never throws).
|
|
118
|
+
*/
|
|
119
|
+
export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
|
|
120
|
+
const fail = failFor(ctx, "land");
|
|
121
|
+
|
|
122
|
+
const r = await runColdDoor<LandPayload>(pi, ctx, ["pr", "land", "--json"], {
|
|
123
|
+
label: "perk pr land",
|
|
124
|
+
decode: decodeLand,
|
|
125
|
+
});
|
|
126
|
+
if (!r.ok) return fail(r.message, r.errorType);
|
|
127
|
+
|
|
128
|
+
// Set the semaphore for the in-session path (idempotent; the worker also set it on disk).
|
|
129
|
+
setMarker(ctx.cwd, PENDING_LEARN);
|
|
130
|
+
|
|
131
|
+
const lines = [`Landed PR #${r.data.pr.number}; run /learn to release the worktree.`];
|
|
132
|
+
const obj = r.data.objective;
|
|
133
|
+
if (obj?.nodes_marked.length && obj.id !== null) {
|
|
134
|
+
// The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
|
|
135
|
+
lines.push(
|
|
136
|
+
`Objective #${obj.id} node(s) ${obj.nodes_marked.join(", ")} marked done — ` +
|
|
137
|
+
`reconciling the roadmap against the merged diff.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (obj?.closed && obj.id !== null) {
|
|
141
|
+
lines.push(`Objective #${obj.id} complete — closed.`);
|
|
142
|
+
}
|
|
143
|
+
const learn = r.data.learn;
|
|
144
|
+
if (learn?.closed.length) {
|
|
145
|
+
// hop-2: the consumed perk:learn issues were closed + labelled perk:consolidated on land.
|
|
146
|
+
lines.push(
|
|
147
|
+
`Closed ${learn.closed.length} learn issue(s) (${learn.closed
|
|
148
|
+
.map((n) => `#${n}`)
|
|
149
|
+
.join(", ")}) into docs/learned.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// Surface a non-benign learn-consume skip: `no_consumed_learn` is the ordinary
|
|
153
|
+
// non-factory case, so stay quiet on it; a real failure must be visible, not silent.
|
|
154
|
+
if (learn?.skipped_reason && !BENIGN_LEARN_SKIPS.has(learn.skipped_reason)) {
|
|
155
|
+
lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return ok(lines.join("\n"), { ...r.data, pending_learn: true }, { terminate: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* After a successful land that marked at least one objective node done, drive the session into the
|
|
163
|
+
* reconcile pass by injecting the exact guidance `/objective-reconcile` injects (warm-door driving
|
|
164
|
+
* pattern). The terminating `land` tool stays terminating — terminate only skips the *automatic*
|
|
165
|
+
* follow-up LLM call, while a `followUp` user message is a separate deliberate new turn. Short-
|
|
166
|
+
* circuits (sends nothing) unless the land succeeded with an objective node marked done — the exact
|
|
167
|
+
* condition that gated the old copy-pasteable nudge.
|
|
168
|
+
*/
|
|
169
|
+
export function driveReconcileAfterLand(
|
|
170
|
+
pi: ExtensionAPI,
|
|
171
|
+
ctx: ExtensionContext,
|
|
172
|
+
details: LandDetails,
|
|
173
|
+
): void {
|
|
174
|
+
if (!details.ok) return;
|
|
175
|
+
const obj = details.objective;
|
|
176
|
+
if (!obj || obj.id === null || obj.nodes_marked.length === 0) return;
|
|
177
|
+
const message = reconcileGuidance(obj.id) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
|
|
178
|
+
if (ctx.isIdle()) {
|
|
179
|
+
// The `/land` command path (idle): inject an immediate turn.
|
|
180
|
+
pi.sendUserMessage(message);
|
|
181
|
+
} else {
|
|
182
|
+
// The `land` tool path (streaming): deliver after the terminating land batch.
|
|
183
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const TOOL_GUIDELINES = [
|
|
188
|
+
"Call land only when the PR is approved and ready to merge; it squash-merges the PR (closing the plan issue) and sets pending-learn.",
|
|
189
|
+
"land operates on the active plan's worktree — it takes no arguments; the PR is discovered from the local plan-ref's branch.",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
/** Register the warm door: the `land` terminating tool + the `/land` command twin. */
|
|
193
|
+
export function registerLand(pi: ExtensionAPI): void {
|
|
194
|
+
pi.registerTool({
|
|
195
|
+
name: "land",
|
|
196
|
+
label: "Land PR",
|
|
197
|
+
description:
|
|
198
|
+
"Merge the active plan's approved PR (squash, closing the plan issue) and set pending-learn. " +
|
|
199
|
+
"Terminating: ends the turn on land. Call only when the PR is ready to merge.",
|
|
200
|
+
promptSnippet: "Squash-merge the approved PR and set pending-learn (terminates the turn)",
|
|
201
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
202
|
+
executionMode: "sequential",
|
|
203
|
+
parameters: { type: "object", additionalProperties: false, properties: {} },
|
|
204
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
205
|
+
const result = await landPr(pi, ctx);
|
|
206
|
+
driveReconcileAfterLand(pi, ctx, result.details);
|
|
207
|
+
return result;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
pi.registerCommand("land", {
|
|
212
|
+
description: "Merge the active plan's PR and set pending-learn (submit → land).",
|
|
213
|
+
handler: async (_args, ctx) => {
|
|
214
|
+
const result = await landPr(pi, ctx);
|
|
215
|
+
// Failure already reported loudly via failFor (the single error surface) — success only.
|
|
216
|
+
if (result.details.ok) {
|
|
217
|
+
report(ctx, "land", "info", result.content[0]?.text ?? "land done");
|
|
218
|
+
}
|
|
219
|
+
driveReconcileAfterLand(pi, ctx, result.details);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// The deepened warm `/learn` door. Graduates the thin marker-clear into a real knowledge-capture
|
|
2
|
+
// pass: when a `summary` is given, DELEGATE to `perk learn capture --json` via the shared cold-door
|
|
3
|
+
// client (`runColdDoor` — the body rides the run-scratch stdin channel; GitHub writes canonical in
|
|
4
|
+
// Python), which creates a `perk:learn` issue + clears
|
|
5
|
+
// `pending-learn`; then mirror the marker-clear in-session (idempotent). With no `summary`, stay
|
|
6
|
+
// the thin TS-only marker-clear (graceful — no empty issue). Never throws (soft `details.ok`);
|
|
7
|
+
// the capture decode is fully LENIENT — a `success: true` envelope always yields the captured-ok
|
|
8
|
+
// terminating result even when `learn_issue` is undecodable (render-only field; see
|
|
9
|
+
// `decodeLearnCapture`).
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
13
|
+
import {
|
|
14
|
+
clearMarker,
|
|
15
|
+
hasMarker,
|
|
16
|
+
PENDING_LEARN,
|
|
17
|
+
type PlanRef,
|
|
18
|
+
readPlanRef,
|
|
19
|
+
} from "../substrate/cache.ts";
|
|
20
|
+
import {
|
|
21
|
+
booleanField,
|
|
22
|
+
type ColdJson,
|
|
23
|
+
objectField,
|
|
24
|
+
runColdDoor,
|
|
25
|
+
stringField,
|
|
26
|
+
} from "../substrate/coldDoor.ts";
|
|
27
|
+
import { render } from "../substrate/prompts.ts";
|
|
28
|
+
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
29
|
+
import { paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
30
|
+
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
31
|
+
import { report } from "../surfaces/report.ts";
|
|
32
|
+
import { planReadInstruction } from "./lifecycleGates.ts";
|
|
33
|
+
|
|
34
|
+
/** The ok-arm fields. */
|
|
35
|
+
export interface LearnOk {
|
|
36
|
+
was_pending: boolean;
|
|
37
|
+
captured: boolean;
|
|
38
|
+
/** `id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
|
|
39
|
+
learn_issue?: { id: string; url: string; existed: boolean };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type LearnResult = Result<LearnOk>;
|
|
43
|
+
|
|
44
|
+
/** The decoded `perk learn capture --json` payload slice the warm door consumes. */
|
|
45
|
+
interface LearnCapturePayload {
|
|
46
|
+
learn_issue?: { id: string; url: string; existed: boolean };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Narrow the `perk learn capture --json` success payload — fully LENIENT, per the decode-policy
|
|
51
|
+
* criterion (strict iff the field is appended to workflow-state; see
|
|
52
|
+
* `docs/learned/workflow/cold-door-client.md`). `learn_issue` is render-only — it feeds only the
|
|
53
|
+
* success message text and `details` — and the `success: true` envelope is the cold door's
|
|
54
|
+
* authoritative statement that the capture mutation completed and the on-disk `pending-learn`
|
|
55
|
+
* marker was already cleared. So any miss on the sub-object (absent key, a legacy `number` shape,
|
|
56
|
+
* mistyped fields — e.g. under CLI↔extension version skew) yields
|
|
57
|
+
* `{ learn_issue: undefined }`, never null: the warm report must survive an undecodable payload,
|
|
58
|
+
* and the `bad_output` arm is deliberately unreachable for this door. `pending_cleared` is
|
|
59
|
+
* unconsumed.
|
|
60
|
+
*/
|
|
61
|
+
function decodeLearnCapture(payload: ColdJson): LearnCapturePayload {
|
|
62
|
+
const issue = objectField(payload, "learn_issue");
|
|
63
|
+
if (issue === undefined) return { learn_issue: undefined };
|
|
64
|
+
const id = stringField(issue, "id");
|
|
65
|
+
const url = stringField(issue, "url");
|
|
66
|
+
const existed = booleanField(issue, "existed");
|
|
67
|
+
if (id === undefined || url === undefined || existed === undefined) {
|
|
68
|
+
return { learn_issue: undefined };
|
|
69
|
+
}
|
|
70
|
+
return { learn_issue: { id, url, existed } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear `pending-learn` (idempotent — a no-op if it was not set). Reports whether it was set. */
|
|
74
|
+
function clearPending(ctx: ExtensionContext): { wasPending: boolean } {
|
|
75
|
+
const wasPending = hasMarker(ctx.cwd, PENDING_LEARN);
|
|
76
|
+
clearMarker(ctx.cwd, PENDING_LEARN);
|
|
77
|
+
return { wasPending };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The single learn implementation both surfaces call. With a `summary`, delegate the capture to the
|
|
82
|
+
* Python cold door (then mirror the marker-clear); without one, stay the thin marker-clear. Returns
|
|
83
|
+
* a soft result (never throws).
|
|
84
|
+
*/
|
|
85
|
+
export async function learnDone(
|
|
86
|
+
pi: ExtensionAPI,
|
|
87
|
+
ctx: ExtensionContext,
|
|
88
|
+
summary?: string,
|
|
89
|
+
): Promise<LearnResult> {
|
|
90
|
+
const trimmed = (summary ?? "").trim();
|
|
91
|
+
|
|
92
|
+
// No summary: the thin, graceful path — just clear the marker (no empty issue).
|
|
93
|
+
if (trimmed.length === 0) {
|
|
94
|
+
const { wasPending } = clearPending(ctx);
|
|
95
|
+
const text = wasPending
|
|
96
|
+
? "Cleared pending-learn — the worktree is releasable. (No summary given; no learn issue created.)"
|
|
97
|
+
: "No pending-learn set — nothing to clear.";
|
|
98
|
+
return ok(text, { was_pending: wasPending, captured: false }, { terminate: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fail = failFor(ctx, "learn");
|
|
102
|
+
|
|
103
|
+
const r = await runColdDoor<LearnCapturePayload>(pi, ctx, ["learn", "capture", "--json"], {
|
|
104
|
+
label: "perk learn capture",
|
|
105
|
+
decode: decodeLearnCapture,
|
|
106
|
+
stdin: { flag: "--body", content: `${trimmed}\n`, filename: `learn-${Date.now()}.md` },
|
|
107
|
+
});
|
|
108
|
+
if (!r.ok) return fail(r.message, r.errorType);
|
|
109
|
+
|
|
110
|
+
// Mirror the marker-clear in-session (idempotent; the worker also cleared it on disk). Runs
|
|
111
|
+
// even when `learn_issue` is undecodable — a success envelope clears the marker.
|
|
112
|
+
const { wasPending } = clearPending(ctx);
|
|
113
|
+
const issue = r.data.learn_issue;
|
|
114
|
+
if (issue === undefined) {
|
|
115
|
+
return ok(
|
|
116
|
+
"Captured learnings; pending-learn cleared. (learn issue details undecodable — the perk " +
|
|
117
|
+
"CLI and the perk extension may be version-skewed.)",
|
|
118
|
+
{ was_pending: wasPending, captured: true },
|
|
119
|
+
{ terminate: true },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const verb = issue.existed ? "Found existing" : "Created";
|
|
123
|
+
return ok(
|
|
124
|
+
`${verb} learn issue #${issue.id}; pending-learn cleared.`,
|
|
125
|
+
{ was_pending: wasPending, captured: true, learn_issue: issue },
|
|
126
|
+
{ terminate: true },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TOOL_GUIDELINES = [
|
|
131
|
+
"Call learn after a plan has landed; pass a `summary` of the durable learnings to capture them in a perk:learn issue (and clear pending-learn). Omit `summary` to just clear the marker.",
|
|
132
|
+
"The summary is captured verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). */
|
|
136
|
+
function activePlanRef(ctx: ExtensionContext): PlanRef | null {
|
|
137
|
+
const fromWorktree = readPlanRef(ctx.cwd);
|
|
138
|
+
if (fromWorktree) return fromWorktree;
|
|
139
|
+
try {
|
|
140
|
+
const branch = branchOf(ctx);
|
|
141
|
+
return (rebuildWorkflowState(branch).active_plan_ref as PlanRef | null) ?? null;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Inject the learn-workflow guidance the model follows (the perk-learn skill pointer rides the
|
|
149
|
+
* skill-binding suffix — not hardcoded here). The wording lives in the canonical template
|
|
150
|
+
* `prompts/stages/learn.md`, rendered identically by both planes via the shared render seam
|
|
151
|
+
* (contracts.md §8.31); the github/linear/other/no-ref branching is the template conditional on
|
|
152
|
+
* `provider` (+ `pr_id` presence), and `read_cmd` is the node-2.1 plan-read instruction. Unified
|
|
153
|
+
* onto the cold `_learn_prompt` body — byte-identical to it for every provider arm (the four
|
|
154
|
+
* `learn-*` golden cases are the cross-plane parity proof). When no plan-ref is known, render the
|
|
155
|
+
* no-ref arm (learn can proceed without a ref — no dead-end null-guard).
|
|
156
|
+
*/
|
|
157
|
+
export function learnGuidance(planRef: PlanRef | null): string {
|
|
158
|
+
if (planRef === null) {
|
|
159
|
+
return render("stages/learn.md", { provider: "", pr_id: "", url: "", read_cmd: "" });
|
|
160
|
+
}
|
|
161
|
+
const read_cmd = planReadInstruction(planRef.provider, planRef.pr_id, planRef.url);
|
|
162
|
+
return render("stages/learn.md", {
|
|
163
|
+
provider: planRef.provider,
|
|
164
|
+
pr_id: planRef.pr_id,
|
|
165
|
+
url: planRef.url,
|
|
166
|
+
read_cmd,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Register the warm door: the `learn` terminating tool + the `/learn` command twin. */
|
|
171
|
+
export function registerLearn(pi: ExtensionAPI): void {
|
|
172
|
+
pi.registerTool({
|
|
173
|
+
name: "learn",
|
|
174
|
+
label: "Finish learn",
|
|
175
|
+
description:
|
|
176
|
+
"Capture learnings from a landed plan into a perk:learn issue (pass `summary`), then clear " +
|
|
177
|
+
"the pending-learn semaphore and release the worktree. Omit `summary` to only clear the marker. " +
|
|
178
|
+
"Terminating: ends the turn.",
|
|
179
|
+
promptSnippet:
|
|
180
|
+
"Capture learnings (optional summary) and clear pending-learn (terminates the turn)",
|
|
181
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
182
|
+
executionMode: "sequential",
|
|
183
|
+
parameters: {
|
|
184
|
+
type: "object",
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
properties: {
|
|
187
|
+
summary: {
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "Markdown learnings to capture in a perk:learn issue. Omit to only clear.",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
194
|
+
// Tool-boundary decode: absent → undefined (the marker-clear path); mistyped →
|
|
195
|
+
// strict-fail — never silently clear the pending-learn marker on uncertainty.
|
|
196
|
+
const p = paramsOf(params);
|
|
197
|
+
const summary = p === null ? undefined : stringParam(p, "summary");
|
|
198
|
+
if (summary === null) {
|
|
199
|
+
return failFor(ctx, "learn")("learn `summary` must be a string", "bad_input");
|
|
200
|
+
}
|
|
201
|
+
return learnDone(pi, ctx, summary);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.registerCommand("learn", {
|
|
206
|
+
description:
|
|
207
|
+
"Investigate the landed change and capture learnings (bare /learn drives the workflow); " +
|
|
208
|
+
"/learn skip clears pending-learn only; /learn <text> captures the text verbatim.",
|
|
209
|
+
handler: async (args, ctx) => {
|
|
210
|
+
const trimmed = (args ?? "").trim();
|
|
211
|
+
|
|
212
|
+
// Explicit text (or `skip`): the existing learnDone path — capture verbatim / marker-clear.
|
|
213
|
+
if (trimmed.length > 0) {
|
|
214
|
+
const summary = trimmed === "skip" ? "" : args;
|
|
215
|
+
const result = await learnDone(pi, ctx, summary);
|
|
216
|
+
// Failure already reported loudly via failFor (the single error surface) — success only.
|
|
217
|
+
if (result.details.ok) {
|
|
218
|
+
report(ctx, "learn", "info", result.content[0]?.text ?? "learn done");
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Bare `/learn`: headless can't drive a turn — stay the safe marker-clear (fail-safe). An
|
|
224
|
+
// interactive session injects the perk-learn guidance so the agent does the capture pass
|
|
225
|
+
// (it clears the marker itself by calling the `learn` tool — do NOT clear it here).
|
|
226
|
+
if (!ctx.hasUI) {
|
|
227
|
+
const result = await learnDone(pi, ctx, "");
|
|
228
|
+
console.error(`perk: /learn invoked (headless) — ${result.content[0]?.text ?? "cleared"}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
report(ctx, "learn", "info", "investigate the landed change and capture learnings");
|
|
232
|
+
pi.sendUserMessage(learnGuidance(activePlanRef(ctx)) + bindingSuffix(ctx.cwd, "stage:learn"));
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// hop-2 — the learned-docs plan factory's warm transition surface: the `/learn-docs` command.
|
|
2
|
+
//
|
|
3
|
+
// The warm twin of the `perk learn docs` cold door. It DELEGATES the gather to the Python plane
|
|
4
|
+
// (`perk learn docs --gather --json` via the shared cold-door client `runColdDoor` — gate-safe,
|
|
5
|
+
// not subject to the read-only bash allowlist), decodes `{ inbox_path, learn_numbers }`, then
|
|
6
|
+
// injects the factory guidance via
|
|
7
|
+
// `pi.sendUserMessage` so the model reads the inbox, authors a docs plan, and calls `plan_save`
|
|
8
|
+
// with `consumed_learn`. No model tool — the model uses the existing `plan_save` tool.
|
|
9
|
+
//
|
|
10
|
+
// Headless-safe: rich UI is guarded by `ctx.hasUI`; without a UI it logs to stderr and returns
|
|
11
|
+
// (the gather still runs so the inbox is materialized, but no turn is driven).
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
15
|
+
import { type ColdJson, runColdDoor, stringField } from "../substrate/coldDoor.ts";
|
|
16
|
+
import { render } from "../substrate/prompts.ts";
|
|
17
|
+
import { report } from "../surfaces/report.ts";
|
|
18
|
+
|
|
19
|
+
/** The decoded `perk learn docs --gather --json` payload slice the warm door consumes. */
|
|
20
|
+
interface LearnDocsGatherPayload {
|
|
21
|
+
inbox_path: string;
|
|
22
|
+
/** Opaque string learn-issue ids (GitHub "45", Linear "ENG-45") — §8.21. */
|
|
23
|
+
learn_numbers: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. */
|
|
27
|
+
function decodeGather(payload: ColdJson): LearnDocsGatherPayload | null {
|
|
28
|
+
const inboxPath = stringField(payload, "inbox_path");
|
|
29
|
+
const numbers = payload.learn_numbers;
|
|
30
|
+
if (inboxPath === undefined) return null;
|
|
31
|
+
// String ids are canonical (§8.21); numbers are tolerated + coerced (older envelopes).
|
|
32
|
+
if (
|
|
33
|
+
!Array.isArray(numbers) ||
|
|
34
|
+
!numbers.every((n) => typeof n === "string" || typeof n === "number")
|
|
35
|
+
) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return { inbox_path: inboxPath, learn_numbers: numbers.map((n) => String(n)) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The seed guidance the warm `/learn-docs` injects to start the factory loop (the perk-learn-docs
|
|
43
|
+
* skill pointer rides the skill-binding suffix — not hardcoded here). Pure + exported
|
|
44
|
+
* for offline tests.
|
|
45
|
+
*/
|
|
46
|
+
export function learnDocsGuidance(inboxPath: string, learnNumbers: string[]): string {
|
|
47
|
+
return render("stages/learn-docs.md", {
|
|
48
|
+
inbox_path: inboxPath,
|
|
49
|
+
num_list: learnNumbers.join(", "),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register the warm learned-docs door: the `/learn-docs` command (no model tool). */
|
|
54
|
+
export function registerLearnDocs(pi: ExtensionAPI): void {
|
|
55
|
+
pi.registerCommand("learn-docs", {
|
|
56
|
+
description:
|
|
57
|
+
"Start the learned-docs plan factory: gather open perk:learn issues into an inbox and author " +
|
|
58
|
+
"a docs/learned consolidation plan.",
|
|
59
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
60
|
+
// Report-only door (no Result type): branch on `errorType` directly (the coldDoor header
|
|
61
|
+
// convention). A clean "nothing to consolidate" exits non-zero with
|
|
62
|
+
// error_type=no_learn_issues — the client's envelope-aware arm surfaces it gently.
|
|
63
|
+
const r = await runColdDoor<LearnDocsGatherPayload>(
|
|
64
|
+
pi,
|
|
65
|
+
ctx,
|
|
66
|
+
["learn", "docs", "--gather", "--json"],
|
|
67
|
+
{ label: "perk learn docs", decode: decodeGather },
|
|
68
|
+
);
|
|
69
|
+
if (!r.ok) {
|
|
70
|
+
if (r.errorType === "no_learn_issues") {
|
|
71
|
+
report(
|
|
72
|
+
ctx,
|
|
73
|
+
"learn-docs",
|
|
74
|
+
"warning",
|
|
75
|
+
"nothing to consolidate (no open perk:learn issues).",
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
report(ctx, "learn-docs", "error", `gather failed: ${r.message}`);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!ctx.hasUI) {
|
|
84
|
+
// Headless can't drive a turn — the inbox is materialized; log and return (fail-safe).
|
|
85
|
+
console.error(
|
|
86
|
+
`perk: /learn-docs invoked (headless) — gathered ${r.data.learn_numbers.length} ` +
|
|
87
|
+
`learn issue(s) into ${r.data.inbox_path}; run interactively to author the docs plan.`,
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
report(ctx, "learn-docs", "info", `gathered ${r.data.learn_numbers.length} learn issue(s)`);
|
|
93
|
+
pi.sendUserMessage(
|
|
94
|
+
learnDocsGuidance(r.data.inbox_path, r.data.learn_numbers) +
|
|
95
|
+
bindingSuffix(ctx.cwd, "command:learn-docs"),
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|