@mgiles/perk 1.1.0 → 2.0.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/README.md +68 -44
- package/extension/adapters/planAdapterPlannotator.ts +27 -41
- package/extension/adapters/planAdapterTombell.ts +15 -28
- package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
- package/extension/checkpoints/checkpoints.ts +19 -12
- package/extension/doors/address.ts +4 -4
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +21 -14
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +31 -9
- package/extension/doors/learn.ts +2 -2
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +4 -4
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/selfcheck.ts +238 -5
- package/extension/doors/submit.ts +20 -0
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/objective.ts +15 -5
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +12 -10
- package/extension/factories/objectiveSave.ts +2 -2
- package/extension/factories/planMode.ts +22 -40
- package/extension/factories/planReview.ts +213 -191
- package/extension/factories/planSave.ts +7 -7
- package/extension/index.ts +83 -25
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +34 -7
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/config.ts +88 -65
- package/extension/substrate/git.ts +43 -0
- package/extension/substrate/paths.ts +1 -1
- package/extension/substrate/prompts.ts +2 -2
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionPointers.ts +35 -6
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +330 -79
- package/extension/substrate/toolParams.ts +7 -0
- package/extension/substrate/workflowState.ts +54 -2
- package/extension/surfaces/footerProvider.ts +8 -4
- package/extension/surfaces/surfaces.ts +330 -12
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +77 -7
- package/extension/workerMain.ts +12 -13
- package/package.json +3 -3
- package/prompts/_fixtures/live.yaml +117 -2
- package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -0
- package/prompts/contexts/adapters/plannotator-plan.md +6 -0
- package/prompts/contexts/adapters/tombell-plan.md +17 -0
- package/prompts/contexts/objective-authoring.md +20 -0
- package/prompts/contexts/plan-authoring.md +24 -0
- package/prompts/contexts/read-only.md +10 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-replan.md +1 -1
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/pr-review-browser/active.md +11 -0
- package/prompts/stages/pr-review-browser/foreign.md +11 -0
- package/prompts/stages/pr-review-terminal/active.md +12 -0
- package/prompts/stages/pr-review-terminal/foreign.md +13 -0
- package/prompts/stages/pr-review-terminal/local.md +4 -0
- package/prompts/stages/pr-review.md +1 -1
- package/prompts/stages/replan.md +2 -2
- package/prompts/stages/skills/create-from.md +1 -1
- package/prompts/stages/skills/create.md +1 -1
- package/prompts/stages/skills/refine.md +1 -1
- package/shared/README.md +22 -18
- package/shared/bindings.yaml +10 -2
- package/shared/contracts-history.md +24 -0
- package/shared/contracts.md +1442 -1787
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +7 -8
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
- package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/extension/doors/learnCode.ts +0 -100
- package/extension/doors/learnDocs.ts +0 -100
- package/extension/doors/prReviewLocal.ts +0 -229
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The perk-owned, read-only CI executor (the Run→Report half of Run→Report→Fix→Verify).
|
|
2
2
|
//
|
|
3
|
-
// A deterministic, in-process check runner: it runs the project's configured `[ci]`
|
|
4
|
-
// via `pi.exec` and REPORTS pass/fail + failure output — it never edits, fixes, or
|
|
3
|
+
// A deterministic, in-process check runner: it runs the project's configured `[[ci.checks]]`
|
|
4
|
+
// named checks via `pi.exec` and REPORTS pass/fail + failure output — it never edits, fixes, or
|
|
5
|
+
// loops. The
|
|
5
6
|
// parent agent (the normal read-write implement session) owns the entire fix loop and all
|
|
6
7
|
// iteration state; this executor is a stateless oracle invoked once per `run_ci` call (the
|
|
7
8
|
// `devrun` discipline: "run and report", never "run and fix").
|
|
@@ -13,14 +14,14 @@
|
|
|
13
14
|
// does NOT call `runReadOnlyChild` (whose `success` means "ran", carrying no exit code).
|
|
14
15
|
//
|
|
15
16
|
// Threat model & the safety boundary (read first):
|
|
16
|
-
// `pi.exec("bash", ["-lc", cmd])` runs whatever the `[ci]` command string says, with
|
|
17
|
-
// filesystem/network access, OUTSIDE perk's tool gate. The defenses, in order, are:
|
|
17
|
+
// `pi.exec("bash", ["-lc", cmd])` runs whatever the `[[ci.checks]]` command string says, with
|
|
18
|
+
// full filesystem/network access, OUTSIDE perk's tool gate. The defenses, in order, are:
|
|
18
19
|
// 1. The model never authors the command — it picks a configured NAME (a persuaded model
|
|
19
20
|
// cannot run `rm -rf` because it cannot supply a command).
|
|
20
21
|
// 2. Untrusted-config scope gate (`decideCiScope`) — running a project-supplied command at
|
|
21
|
-
// all requires `[
|
|
22
|
+
// all requires `[ci] trusted = true` (committed config), `--allow-project-ci`, an
|
|
22
23
|
// interactive confirm, or a per-session approval latch; headless with none REFUSES (fail
|
|
23
|
-
// closed). This is the real defense against
|
|
24
|
+
// closed). This is the real defense against malicious cloned-repo `[[ci.checks]]` rows.
|
|
24
25
|
// 3. Output isolation — full output to scratch, capped + `<untrusted_ci_output>`-wrapped in
|
|
25
26
|
// the parent's view (prompt-injection-in-stdout hygiene).
|
|
26
27
|
// A true OS/tool sandbox around the check command is explicitly OUT OF SCOPE.
|
|
@@ -94,7 +95,7 @@ export type CiScope = "run" | "confirm" | "refuse";
|
|
|
94
95
|
|
|
95
96
|
/**
|
|
96
97
|
* Decide how to treat project-supplied CI. Pure (the load-bearing safety boundary):
|
|
97
|
-
* - `[
|
|
98
|
+
* - `[ci] trusted` (committed config), `--allow-project-ci`, or a per-session latch ⇒ "run"
|
|
98
99
|
* (trust runs on EVERY surface, overriding the headless refuse below)
|
|
99
100
|
* - else with UI ⇒ "confirm" (ask the human)
|
|
100
101
|
* - else (headless, no trust/flag) ⇒ "refuse" (fail closed)
|
|
@@ -164,7 +165,9 @@ export async function runOneCheck(
|
|
|
164
165
|
writeError = err instanceof Error ? err.message : String(err);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
// Tail-keep: pytest/tsc failure summaries live at the END of the output, so the model-visible
|
|
169
|
+
// slice keeps the last `cap` bytes; the scratch file still holds the full output.
|
|
170
|
+
const capped = capForModel(outcome.output, cap, scratchPath, "tail");
|
|
168
171
|
return {
|
|
169
172
|
name,
|
|
170
173
|
command,
|
|
@@ -368,7 +371,7 @@ export function renderCiProse(report: CiReport): string {
|
|
|
368
371
|
);
|
|
369
372
|
}
|
|
370
373
|
if (report.error_type === "no_checks_configured") {
|
|
371
|
-
return "No CI checks configured ([[ci]] in .perk/config.toml is empty). Nothing to run.";
|
|
374
|
+
return "No CI checks configured ([[ci.checks]] in .perk/config.toml is empty). Nothing to run.";
|
|
372
375
|
}
|
|
373
376
|
if (report.error_type === "unknown_check") {
|
|
374
377
|
return `perk CI: ${report.error}`;
|
|
@@ -398,6 +401,9 @@ export function renderCiProse(report: CiReport): string {
|
|
|
398
401
|
lines.push(c.shown || "(no output captured)");
|
|
399
402
|
lines.push("</untrusted_ci_output>");
|
|
400
403
|
}
|
|
404
|
+
// Deliberately head-capped (unlike the per-check tail cap): the prose leads with the ✓/✗
|
|
405
|
+
// per-check summary and the scratch-path pointers — the actionable routing info a tail cap
|
|
406
|
+
// would drop.
|
|
401
407
|
return capForModel(lines.join("\n"), DEFAULT_MODEL_VISIBLE_CAP).shown;
|
|
402
408
|
}
|
|
403
409
|
|
|
@@ -433,8 +439,9 @@ interface ApprovalLatch {
|
|
|
433
439
|
}
|
|
434
440
|
|
|
435
441
|
/**
|
|
436
|
-
* The single `run_ci`/`/ci` implementation. Loads `[ci]`, scopes the run (the
|
|
437
|
-
* gate), runs the selected check(s) deterministically, and returns
|
|
442
|
+
* The single `run_ci`/`/ci` implementation. Loads `[[ci.checks]]`, scopes the run (the
|
|
443
|
+
* untrusted-config gate), runs the selected check(s) deterministically, and returns
|
|
444
|
+
* double-delivery. Never throws.
|
|
438
445
|
*/
|
|
439
446
|
async function runCiImpl(
|
|
440
447
|
pi: ExtensionAPI,
|
|
@@ -444,7 +451,7 @@ async function runCiImpl(
|
|
|
444
451
|
deps: RunCiDeps = {},
|
|
445
452
|
): Promise<CiResult> {
|
|
446
453
|
const cfg = loadPerkConfig(ctx.cwd);
|
|
447
|
-
const checks: CiCheck[] = cfg.ci;
|
|
454
|
+
const checks: CiCheck[] = cfg.ci.checks;
|
|
448
455
|
const wrap = (report: CiReport): CiResult => ({
|
|
449
456
|
content: [{ type: "text", text: renderCiProse(report) }],
|
|
450
457
|
details: report,
|
|
@@ -456,7 +463,7 @@ async function runCiImpl(
|
|
|
456
463
|
if (checks.length > 0) {
|
|
457
464
|
const decideScope = deps.decideScope ?? decideCiScope;
|
|
458
465
|
const allowFlag = pi.getFlag("allow-project-ci") === true;
|
|
459
|
-
const trusted = cfg.
|
|
466
|
+
const trusted = cfg.ci.trusted;
|
|
460
467
|
const scope = decideScope({ hasUI: ctx.hasUI, allowFlag, approved: latch.approved, trusted });
|
|
461
468
|
|
|
462
469
|
if (scope === "refuse") {
|
|
@@ -507,7 +514,7 @@ async function runCiImpl(
|
|
|
507
514
|
const TOOL_GUIDELINES = [
|
|
508
515
|
"run_ci RUNS the configured CI checks and REPORTS results — it never edits, fixes, or loops.",
|
|
509
516
|
"Analyze any failure yourself, fix it in your own turn, then call run_ci again to re-verify.",
|
|
510
|
-
"
|
|
517
|
+
"Pass run_ci a single configured check name to run just that check; omit it to run all checks in declared order.",
|
|
511
518
|
"You own the Run→Report→Fix→Verify loop; run_ci is a stateless oracle, not an auto-fixer.",
|
|
512
519
|
];
|
|
513
520
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The hunk terminal-review substrate serving `/pr-review-terminal`: the strict
|
|
2
|
+
// `perk pr review checkout` decode, the `hunk --version` presence probe, and the R7 launch
|
|
3
|
+
// handoff (clipboard copy + terminal auto-launch raced against a soft deadline) — plus the
|
|
4
|
+
// door-common PR-token arg grammar (`parseReviewArgs`) that `/pr-review-browser` also consumes
|
|
5
|
+
// via `parseReviewDoorArgs`. Re-homing the door-common pieces to a neutral module is a deferred
|
|
6
|
+
// residual (accepted — no code moves yet).
|
|
7
|
+
|
|
8
|
+
import { copyToClipboard } from "../substrate/clipboard.ts";
|
|
9
|
+
import { type ColdJson, type ExecHost, numberField, stringField } from "../substrate/coldDoor.ts";
|
|
10
|
+
import { LAUNCH_SURFACE, launchInTerminal } from "../substrate/terminalLaunch.ts";
|
|
11
|
+
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
12
|
+
|
|
13
|
+
/** The install hint for the absent hunk binary — the exact `HUNK_INSTALL_HINT` wording
|
|
14
|
+
* (src/perk/convergence/init/review_cli.py). */
|
|
15
|
+
export const HUNK_INSTALL_HINT = "npm i -g hunkdiff (or brew install hunk)";
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------------------ the PR arg grammar
|
|
18
|
+
|
|
19
|
+
/** A parsed PR-review invocation: the PR number + the optional free-form focus directive. */
|
|
20
|
+
export interface ReviewArgs {
|
|
21
|
+
pr: number;
|
|
22
|
+
directive: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Extracts the PR number from a GitHub PR URL (with optional trailing /path, #fragment, ?query). */
|
|
26
|
+
export const PR_URL_RE = /\/pull\/(\d+)(?:\/|$|#|\?)/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a PR-review arg string: the first token is a bare PR number or a GitHub PR URL; the rest
|
|
30
|
+
* is the optional free-form focus directive. Null on a missing/unparseable PR (usage failure).
|
|
31
|
+
* Cross-repo URLs are NOT validated against the session repo — the number is extracted as-is.
|
|
32
|
+
*/
|
|
33
|
+
export function parseReviewArgs(args: string): ReviewArgs | null {
|
|
34
|
+
const trimmed = args.trim();
|
|
35
|
+
if (trimmed.length === 0) return null;
|
|
36
|
+
const split = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
37
|
+
if (split === null) return null;
|
|
38
|
+
const first = split[1] ?? "";
|
|
39
|
+
const directive = (split[2] ?? "").trim();
|
|
40
|
+
let pr: number | null = null;
|
|
41
|
+
if (/^\d+$/.test(first)) {
|
|
42
|
+
pr = Number(first);
|
|
43
|
+
} else {
|
|
44
|
+
const url = first.match(PR_URL_RE);
|
|
45
|
+
if (url?.[1] !== undefined) pr = Number(url[1]);
|
|
46
|
+
}
|
|
47
|
+
if (pr === null || !Number.isSafeInteger(pr) || pr <= 0) return null;
|
|
48
|
+
return { pr, directive };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------------ checkout decode
|
|
52
|
+
|
|
53
|
+
/** The `perk pr review checkout --json` ok-arm (all six fields always emitted — strict). */
|
|
54
|
+
export interface CheckoutOk {
|
|
55
|
+
path: string;
|
|
56
|
+
pr: number;
|
|
57
|
+
url: string;
|
|
58
|
+
head_sha: string;
|
|
59
|
+
base_sha: string;
|
|
60
|
+
base_ref: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Strict decode of the checkout payload — the guidance dereferences `path`/`base_sha`/`url`. */
|
|
64
|
+
export function decodeCheckout(payload: ColdJson): CheckoutOk | null {
|
|
65
|
+
const path = stringField(payload, "path");
|
|
66
|
+
const pr = numberField(payload, "pr");
|
|
67
|
+
const url = stringField(payload, "url");
|
|
68
|
+
const headSha = stringField(payload, "head_sha");
|
|
69
|
+
const baseSha = stringField(payload, "base_sha");
|
|
70
|
+
const baseRef = stringField(payload, "base_ref");
|
|
71
|
+
if (
|
|
72
|
+
path === undefined ||
|
|
73
|
+
pr === undefined ||
|
|
74
|
+
url === undefined ||
|
|
75
|
+
headSha === undefined ||
|
|
76
|
+
baseSha === undefined ||
|
|
77
|
+
baseRef === undefined
|
|
78
|
+
) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return { path, pr, url, head_sha: headSha, base_sha: baseSha, base_ref: baseRef };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------------ the hunk probe
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether the `hunk` review CLI is present — the `hunk --version` probe (`!killed && code === 0`;
|
|
88
|
+
* any throw ⇒ false). The terminal door refuses-at-start on a false, before any cold-door call.
|
|
89
|
+
*/
|
|
90
|
+
export async function hunkPresent(
|
|
91
|
+
pi: ExecHost,
|
|
92
|
+
ctx: { cwd: string; signal?: AbortSignal },
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
try {
|
|
95
|
+
const probe = await pi.exec("hunk", ["--version"], { cwd: ctx.cwd, signal: ctx.signal });
|
|
96
|
+
return !probe.killed && probe.code === 0;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------------------------------ the R7 handoff
|
|
103
|
+
|
|
104
|
+
/** The minimal ctx slice the hunk launch handoff needs (report + the exec cwd/signal). */
|
|
105
|
+
interface ReviewLaunchCtx extends ReportTarget {
|
|
106
|
+
cwd: string;
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The default soft deadline the launch is raced against (ms) — see `handleHunkLaunch`. */
|
|
111
|
+
const LAUNCH_SOFT_DEADLINE_MS = 2000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The soft-deadline knob: `PERK_REVIEW_LAUNCH_DEADLINE_MS` (an internal test seam — documented
|
|
115
|
+
* in contracts §8.4 beside the two human-facing seams, never in user docs; tests drive the whole
|
|
116
|
+
* command handler, so an env knob is the only injectable surface). Falls back to the 2s default.
|
|
117
|
+
*/
|
|
118
|
+
function softDeadlineMs(): number {
|
|
119
|
+
const raw = process.env.PERK_REVIEW_LAUNCH_DEADLINE_MS;
|
|
120
|
+
if (raw !== undefined) {
|
|
121
|
+
const n = Number(raw);
|
|
122
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
123
|
+
}
|
|
124
|
+
return LAUNCH_SOFT_DEADLINE_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A cancellable `null`-resolving timer — the soft-deadline arm of the launch race. The caller
|
|
129
|
+
* cancels it once the race settles so a won race never leaves a live timer behind (a leak in
|
|
130
|
+
* production; a hang/latency drag under test runners that wait for pending timers).
|
|
131
|
+
*/
|
|
132
|
+
function delay(ms: number): { promise: Promise<null>; cancel: () => void } {
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
const promise = new Promise<null>((resolve) => {
|
|
135
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
promise,
|
|
139
|
+
cancel: () => {
|
|
140
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The hunk R7 handoff (contracts §8.4). Copies the launch command to the clipboard, then
|
|
147
|
+
* auto-launches hunk in a terminal the human can see — raced against a soft deadline so a
|
|
148
|
+
* first-run macOS Automation/TCC dialog (which blocks `osascript` until answered) never inserts
|
|
149
|
+
* ~18s of silence before the guidance injection. Reports the outcome under `opts.scope` (the
|
|
150
|
+
* invoking door's report scope): an **info** "opened hunk" when the launch settled cleanly within
|
|
151
|
+
* the deadline, else a **warning** "ACTION NEEDED" (the launch failed, no rung matched, or it is
|
|
152
|
+
* still pending). For the pending case, a background follow-up info note lands if/when consent is
|
|
153
|
+
* granted and the launch succeeds. Non-blocking: the caller injects the guidance immediately
|
|
154
|
+
* either way. The `hunk session get` handshake (driven by the model per the template) — never a
|
|
155
|
+
* spawn success — remains the only verification hunk is up.
|
|
156
|
+
*/
|
|
157
|
+
export async function handleHunkLaunch(
|
|
158
|
+
pi: ExecHost,
|
|
159
|
+
ctx: ReviewLaunchCtx,
|
|
160
|
+
opts: { cwd: string; hunkCmd: string; launchLine: string; scope: string },
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const copied = await copyToClipboard(pi, ctx, opts.launchLine);
|
|
163
|
+
const clip = copied ? " (it's on your clipboard)" : "";
|
|
164
|
+
const settled = launchInTerminal(pi, ctx, { cwd: opts.cwd, command: opts.hunkCmd });
|
|
165
|
+
const deadline = delay(softDeadlineMs());
|
|
166
|
+
const quick = await Promise.race([settled, deadline.promise]);
|
|
167
|
+
deadline.cancel();
|
|
168
|
+
if (quick?.launched) {
|
|
169
|
+
report(
|
|
170
|
+
ctx,
|
|
171
|
+
opts.scope,
|
|
172
|
+
"info",
|
|
173
|
+
`opened hunk in a new ${LAUNCH_SURFACE[quick.via ?? "terminal-app"]} — if nothing ` +
|
|
174
|
+
`appeared, run this in another terminal:\n ${opts.launchLine}${clip}`,
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
report(
|
|
179
|
+
ctx,
|
|
180
|
+
opts.scope,
|
|
181
|
+
"warning",
|
|
182
|
+
`ACTION NEEDED — run hunk in another terminal:\n ${opts.launchLine}${clip}`,
|
|
183
|
+
);
|
|
184
|
+
if (quick === null) {
|
|
185
|
+
// Still pending past the soft deadline (a first-run TCC dialog): proceed now, and note it if
|
|
186
|
+
// consent is granted and the launch lands so the human can ignore the manual step.
|
|
187
|
+
// `launchInTerminal` never rejects by contract — the `.catch` is belt-and-braces.
|
|
188
|
+
void settled
|
|
189
|
+
.then((r) => {
|
|
190
|
+
if (r.launched) {
|
|
191
|
+
report(
|
|
192
|
+
ctx,
|
|
193
|
+
opts.scope,
|
|
194
|
+
"info",
|
|
195
|
+
`hunk opened in a new ${LAUNCH_SURFACE[r.via ?? "terminal-app"]} — ignore the ` +
|
|
196
|
+
"manual step above",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
}
|
package/extension/doors/land.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// The warm `/land` door. The in-session twin of the Python cold door
|
|
2
2
|
// (`perk pr land`): a terminating tool + command that DELEGATE the GitHub merge (mutations
|
|
3
|
-
// canonical in Python), then
|
|
4
|
-
//
|
|
3
|
+
// canonical in Python), then mirror the envelope's `pending_learn` for the in-session path —
|
|
4
|
+
// setting the `pending-learn` marker (an idempotent existence-semaphore; the worker sets it too
|
|
5
|
+
// on the cold path) unless the cold door reports the learn-docs exemption (`pending_learn:
|
|
6
|
+
// false` — no marker, no /learn nudge). Never throws.
|
|
5
7
|
|
|
6
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
9
|
import { reconcileGuidance } from "../factories/objectivePlan.ts";
|
|
8
10
|
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
9
11
|
import { PENDING_LEARN, setMarker } from "../substrate/cache.ts";
|
|
10
12
|
import {
|
|
13
|
+
booleanField,
|
|
11
14
|
type ColdJson,
|
|
12
15
|
nullableStringField,
|
|
13
16
|
numberField,
|
|
@@ -37,7 +40,8 @@ export interface LearnConsumeUpdate {
|
|
|
37
40
|
skipped_reason: string | null;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
|
|
43
|
+
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
|
|
44
|
+
* `pending_learn` mirrors the cold envelope: `false` is the learn-docs exemption (no marker). */
|
|
41
45
|
export interface LandOk {
|
|
42
46
|
pr: { number: number; state: string };
|
|
43
47
|
branch?: string;
|
|
@@ -51,11 +55,13 @@ export interface LandOk {
|
|
|
51
55
|
export type LandResult = Result<LandOk>;
|
|
52
56
|
export type LandDetails = LandResult["details"];
|
|
53
57
|
|
|
54
|
-
/** The decoded `perk pr land --json` payload —
|
|
58
|
+
/** The decoded `perk pr land --json` payload — the cold door owns `pending_learn` (the
|
|
59
|
+
* learn-docs-exemption decision point); decoded leniently so skew degrades to legacy. */
|
|
55
60
|
interface LandPayload {
|
|
56
61
|
pr: { number: number; state: string };
|
|
57
62
|
branch?: string;
|
|
58
63
|
issue?: string;
|
|
64
|
+
pending_learn: boolean;
|
|
59
65
|
objective?: ObjectiveLandUpdate;
|
|
60
66
|
learn?: LearnConsumeUpdate;
|
|
61
67
|
}
|
|
@@ -108,6 +114,10 @@ function decodeLand(payload: ColdJson): LandPayload | null {
|
|
|
108
114
|
pr: { number, state },
|
|
109
115
|
branch: stringField(payload, "branch"),
|
|
110
116
|
issue: stringField(payload, "issue"),
|
|
117
|
+
// Lenient: a missing/mistyped `pending_learn` (an older cold CLI under version skew)
|
|
118
|
+
// defaults to the legacy behavior (marker + /learn nudge) — never a silently-unreleased
|
|
119
|
+
// marker with no visible nudge.
|
|
120
|
+
pending_learn: booleanField(payload, "pending_learn") ?? true,
|
|
111
121
|
objective: decodeObjective(payload),
|
|
112
122
|
learn: decodeLearn(payload),
|
|
113
123
|
};
|
|
@@ -115,7 +125,9 @@ function decodeLand(payload: ColdJson): LandPayload | null {
|
|
|
115
125
|
|
|
116
126
|
/**
|
|
117
127
|
* The single land implementation both surfaces call. Delegates the merge to the Python cold door,
|
|
118
|
-
* then
|
|
128
|
+
* then mirrors the envelope's `pending_learn` (in-session path): marker + /learn nudge on the
|
|
129
|
+
* ordinary arm; no marker, no nudge on the learn-docs exemption. Returns a soft result
|
|
130
|
+
* (never throws).
|
|
119
131
|
*/
|
|
120
132
|
export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
|
|
121
133
|
const fail = failFor(ctx, "land");
|
|
@@ -126,10 +138,16 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
|
|
|
126
138
|
});
|
|
127
139
|
if (!r.ok) return fail(r.message, r.errorType);
|
|
128
140
|
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
if (r.data.pending_learn) {
|
|
142
|
+
// Set the semaphore for the in-session path (idempotent; the cold door also set it on disk).
|
|
143
|
+
setMarker(ctx.cwd, PENDING_LEARN);
|
|
144
|
+
}
|
|
131
145
|
|
|
132
|
-
const lines = [
|
|
146
|
+
const lines = [
|
|
147
|
+
r.data.pending_learn
|
|
148
|
+
? `Landed PR #${r.data.pr.number}; run /learn to release the worktree.`
|
|
149
|
+
: `Landed PR #${r.data.pr.number}; learn-docs plan — no learn pass needed; the worktree is releasable.`,
|
|
150
|
+
];
|
|
133
151
|
const obj = r.data.objective;
|
|
134
152
|
if (obj?.nodes_marked.length && obj.id !== null) {
|
|
135
153
|
// The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
|
|
@@ -156,7 +174,11 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
|
|
|
156
174
|
lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
|
|
157
175
|
}
|
|
158
176
|
|
|
159
|
-
return ok(
|
|
177
|
+
return ok(
|
|
178
|
+
lines.join("\n"),
|
|
179
|
+
{ ...r.data, pending_learn: r.data.pending_learn },
|
|
180
|
+
{ terminate: true },
|
|
181
|
+
);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/**
|
package/extension/doors/learn.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// `/learn <text>` / `/learn skip` stay the existing verbatim-capture / skip-recording paths
|
|
25
25
|
// (decision-less escape hatches). Cold `perk learn` launch stays the simple investigate+capture.
|
|
26
26
|
//
|
|
27
|
-
// The analyst model is configurable via `[subagents] learn-analyst` in `.perk/config.toml`; because
|
|
27
|
+
// The analyst model is configurable via `[models.subagents] learn-analyst` in `.perk/config.toml`; because
|
|
28
28
|
// `subagents.agentOverrides` does NOT reach project agents, the orchestration seed injects that
|
|
29
29
|
// model as a per-call inline `model` override on every analyst spawn.
|
|
30
30
|
|
|
@@ -226,7 +226,7 @@ export async function learnDone(
|
|
|
226
226
|
|
|
227
227
|
const TOOL_GUIDELINES = [
|
|
228
228
|
"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 record the skip on the plan and clear the marker.",
|
|
229
|
-
"
|
|
229
|
+
"learn captures the summary verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
|
|
230
230
|
];
|
|
231
231
|
|
|
232
232
|
/** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). */
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// hop-2 — the two learn plan factories' warm transition surfaces: the `/learn-docs` and
|
|
2
|
+
// `/learn-code` commands (the warm twins of the `perk learn docs` / `perk learn code` cold
|
|
3
|
+
// doors). One shared register parameterized by a kind config — mirroring the Python plane's
|
|
4
|
+
// `factory_common.py` (`LearnFactoryKind` + `DOCS_FACTORY`/`CODE_FACTORY` + `run_factory`).
|
|
5
|
+
//
|
|
6
|
+
// Each door DELEGATES the gather to the Python plane (`perk learn <kind> --gather --json` via the
|
|
7
|
+
// shared cold-door client `runColdDoor` — gate-safe, not subject to the read-only bash allowlist),
|
|
8
|
+
// decodes `{ inbox_path, learn_numbers }`, then injects the factory guidance via
|
|
9
|
+
// `pi.sendUserMessage` so the model reads the inbox, authors the plan, and saves it. The save is
|
|
10
|
+
// surface-dependent (the seed spells this out): where `plan_save` is active (a read-write session,
|
|
11
|
+
// the warm doors' usual host) the model passes `consumed_learn` explicitly — load-bearing here,
|
|
12
|
+
// because the warm gather is side-effect-free and writes NO handoff carrier; in a gated read-only
|
|
13
|
+
// session `plan_save` is hidden (toolGating.ts), so the save lands review-first via `plan_review`
|
|
14
|
+
// and the COLD doors' handoff carrier supplies `consumed_learn`. No model tool is registered here.
|
|
15
|
+
//
|
|
16
|
+
// Headless-safe: rich UI is guarded by `ctx.hasUI`; without a UI it logs to stderr and returns
|
|
17
|
+
// (the gather still runs so the inbox is materialized, but no turn is driven).
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
21
|
+
import { type ColdJson, runColdDoor, stringField } from "../substrate/coldDoor.ts";
|
|
22
|
+
import { registerPerkCommand } from "../substrate/command.ts";
|
|
23
|
+
import { render } from "../substrate/prompts.ts";
|
|
24
|
+
import { report } from "../surfaces/report.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The per-door parameter bundle shared by the two warm learn-factory doors (the TS twin of the
|
|
28
|
+
* frozen `LearnFactoryKind` dataclass). `subcommand` derives the cold argv, the `runColdDoor`
|
|
29
|
+
* label, and the headless log tail; `seedTemplate` and `bindingTrigger` stay explicit so the
|
|
30
|
+
* strings remain greppable against `prompts/stages/` and `shared/bindings.yaml`.
|
|
31
|
+
*/
|
|
32
|
+
export interface LearnFactoryDoorKind {
|
|
33
|
+
/** The command id and `report()` scope. */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** The cold-door verb under `perk learn`. */
|
|
36
|
+
readonly subcommand: string;
|
|
37
|
+
readonly seedTemplate: string;
|
|
38
|
+
readonly bindingTrigger: string;
|
|
39
|
+
/** The `registerPerkCommand` description. */
|
|
40
|
+
readonly description: string;
|
|
41
|
+
/** The gentle `no_learn_issues` warning. */
|
|
42
|
+
readonly emptyMessage: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DOCS_DOOR: LearnFactoryDoorKind = {
|
|
46
|
+
name: "learn-docs",
|
|
47
|
+
subcommand: "docs",
|
|
48
|
+
seedTemplate: "stages/learn-docs.md",
|
|
49
|
+
bindingTrigger: "command:learn-docs",
|
|
50
|
+
description:
|
|
51
|
+
"Start the learned-docs plan factory: gather open perk:learn issues into an inbox and author " +
|
|
52
|
+
"a docs/learned consolidation plan.",
|
|
53
|
+
emptyMessage: "nothing to consolidate (no open perk:learn issues).",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const CODE_DOOR: LearnFactoryDoorKind = {
|
|
57
|
+
name: "learn-code",
|
|
58
|
+
subcommand: "code",
|
|
59
|
+
seedTemplate: "stages/learn-code.md",
|
|
60
|
+
bindingTrigger: "command:learn-code",
|
|
61
|
+
description:
|
|
62
|
+
"Start the learn-code plan factory: gather pre-stamped SHOULD_BE_CODE perk:learn issues into " +
|
|
63
|
+
"an inbox and author a plan routing each into its real code home.",
|
|
64
|
+
emptyMessage: "nothing to route into code (no SHOULD_BE_CODE perk:learn issues).",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The decoded `perk learn <kind> --gather --json` payload slice the warm door consumes. */
|
|
68
|
+
export interface LearnGatherPayload {
|
|
69
|
+
inbox_path: string;
|
|
70
|
+
/** Opaque string learn-issue ids (GitHub "45", Linear "ENG-45") — §8.21. */
|
|
71
|
+
learn_numbers: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. Exported for offline reject-branch tests. */
|
|
75
|
+
export function decodeGather(payload: ColdJson): LearnGatherPayload | null {
|
|
76
|
+
const inboxPath = stringField(payload, "inbox_path");
|
|
77
|
+
const numbers = payload.learn_numbers;
|
|
78
|
+
if (inboxPath === undefined) return null;
|
|
79
|
+
// String ids are canonical (§8.21); numbers are tolerated + coerced (older envelopes).
|
|
80
|
+
if (
|
|
81
|
+
!Array.isArray(numbers) ||
|
|
82
|
+
!numbers.every((n) => typeof n === "string" || typeof n === "number")
|
|
83
|
+
) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return { inbox_path: inboxPath, learn_numbers: numbers.map((n) => String(n)) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The seed guidance the warm door injects to start the factory loop (the per-kind skill pointer
|
|
91
|
+
* rides the skill-binding suffix — not hardcoded here). Pure + exported for offline tests.
|
|
92
|
+
*/
|
|
93
|
+
export function learnFactoryGuidance(
|
|
94
|
+
kind: LearnFactoryDoorKind,
|
|
95
|
+
inboxPath: string,
|
|
96
|
+
learnNumbers: string[],
|
|
97
|
+
): string {
|
|
98
|
+
return render(kind.seedTemplate, {
|
|
99
|
+
inbox_path: inboxPath,
|
|
100
|
+
num_list: learnNumbers.join(", "),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register one warm learn-factory door: the `/<kind.name>` command (no model tool). */
|
|
105
|
+
export function registerLearnFactoryDoor(pi: ExtensionAPI, kind: LearnFactoryDoorKind): void {
|
|
106
|
+
registerPerkCommand(pi, kind.name, {
|
|
107
|
+
description: kind.description,
|
|
108
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
109
|
+
// Report-only door (no Result type): branch on `errorType` directly (the coldDoor header
|
|
110
|
+
// convention). A clean empty inbox exits non-zero with error_type=no_learn_issues — the
|
|
111
|
+
// client's envelope-aware arm surfaces it gently.
|
|
112
|
+
const r = await runColdDoor<LearnGatherPayload>(
|
|
113
|
+
pi,
|
|
114
|
+
ctx,
|
|
115
|
+
["learn", kind.subcommand, "--gather", "--json"],
|
|
116
|
+
{ label: `perk learn ${kind.subcommand}`, decode: decodeGather },
|
|
117
|
+
);
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
if (r.errorType === "no_learn_issues") {
|
|
120
|
+
report(ctx, kind.name, "warning", kind.emptyMessage);
|
|
121
|
+
} else {
|
|
122
|
+
report(ctx, kind.name, "error", `gather failed: ${r.message}`);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!ctx.hasUI) {
|
|
128
|
+
// Headless can't drive a turn — the inbox is materialized; log and return (fail-safe).
|
|
129
|
+
console.error(
|
|
130
|
+
`perk: /${kind.name} invoked (headless) — gathered ${r.data.learn_numbers.length} ` +
|
|
131
|
+
`learn issue(s) into ${r.data.inbox_path}; run interactively to author the ` +
|
|
132
|
+
`${kind.subcommand} plan.`,
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
report(ctx, kind.name, "info", `gathered ${r.data.learn_numbers.length} learn issue(s)`);
|
|
138
|
+
pi.sendUserMessage(
|
|
139
|
+
learnFactoryGuidance(kind, r.data.inbox_path, r.data.learn_numbers) +
|
|
140
|
+
bindingSuffix(ctx.cwd, kind.bindingTrigger),
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|