@mgiles/perk 1.0.1 → 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 +67 -57
- 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 +21 -13
- package/extension/doors/address.ts +6 -5
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +23 -15
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +33 -10
- package/extension/doors/learn.ts +240 -34
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/lifecycleGates.ts +2 -1
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +22 -37
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/ready.ts +2 -1
- package/extension/doors/selfcheck.ts +240 -6
- package/extension/doors/submit.ts +37 -19
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/implementHere.ts +116 -0
- package/extension/factories/objective.ts +17 -6
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +16 -34
- package/extension/factories/objectiveSave.ts +7 -17
- package/extension/factories/planMode.ts +24 -38
- package/extension/factories/planReview.ts +291 -177
- package/extension/factories/planSave.ts +23 -8
- package/extension/index.ts +117 -20
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +36 -9
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/command.ts +25 -0
- package/extension/substrate/config.ts +98 -76
- package/extension/substrate/consoleCapture.ts +90 -0
- package/extension/substrate/git.ts +77 -0
- package/extension/substrate/miniJinja.ts +480 -0
- package/extension/substrate/paths.ts +38 -0
- package/extension/substrate/prompts.ts +15 -24
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionData.ts +1 -1
- package/extension/substrate/sessionPointers.ts +184 -0
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +332 -73
- 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 +338 -13
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +187 -31
- package/extension/workerMain.ts +14 -13
- package/package.json +3 -7
- package/prompts/README.md +56 -5
- package/prompts/_fixtures/cases.yaml +52 -131
- package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
- package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trim_block.txt +3 -0
- package/prompts/_fixtures/golden/trim_inline.txt +1 -0
- package/prompts/_fixtures/live.yaml +396 -0
- package/prompts/_fixtures/templates/cond_elif.md +9 -0
- package/prompts/_fixtures/templates/cond_if.md +7 -0
- package/prompts/_fixtures/templates/cond_ops.md +3 -0
- package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trim_block.md +5 -0
- package/prompts/_fixtures/templates/trim_inline.md +1 -0
- 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 +4 -0
- package/prompts/stages/learn-code.md +8 -0
- package/prompts/stages/learn-docs.md +7 -6
- package/prompts/stages/learn-orchestrate.md +6 -0
- package/prompts/stages/learn.md +1 -1
- package/prompts/stages/objective-author/adopt.md +12 -0
- package/prompts/stages/objective-author/file.md +9 -0
- package/prompts/stages/objective-author/seed.md +9 -0
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +3 -2
- package/prompts/stages/objective-reconcile.md +7 -0
- package/prompts/stages/objective-replan.md +14 -0
- package/prompts/stages/objective-save.md +9 -0
- package/prompts/stages/plan-from/adopt.md +10 -0
- package/prompts/stages/plan-from/file.md +9 -0
- 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 +6 -0
- package/prompts/stages/replan.md +13 -0
- package/prompts/stages/skills/create-from.md +15 -0
- package/prompts/stages/skills/create.md +9 -0
- package/prompts/stages/skills/refine.md +9 -0
- package/shared/README.md +23 -13
- package/shared/bindings.yaml +22 -2
- package/shared/contracts-history.md +191 -0
- package/shared/contracts.md +2258 -1638
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +10 -11
- package/shared/schemas/contracts/bindings.schema.json +38 -0
- package/shared/schemas/contracts/providers.schema.json +89 -0
- package/shared/schemas/contracts/registry.schema.json +98 -0
- package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
- package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
- package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
- package/shared/schemas/outputs/doctor-report.schema.json +236 -0
- package/shared/schemas/outputs/init-report.schema.json +419 -0
- package/shared/schemas/outputs/learn-capture.schema.json +90 -0
- package/shared/schemas/outputs/learn-skip.schema.json +59 -0
- package/shared/schemas/outputs/plan-save.schema.json +209 -0
- package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
- package/shared/schemas/outputs/pr-land.schema.json +187 -0
- package/shared/schemas/outputs/pr-ready.schema.json +75 -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-context.schema.json +86 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/shared/schemas/outputs/pr-submit.schema.json +147 -0
- package/extension/doors/learnDocs.ts +0 -99
- package/prompts/_fixtures/golden/address-action-model.txt +0 -10
- package/prompts/_fixtures/golden/address-action.txt +0 -10
- package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
- package/prompts/_fixtures/golden/address-preview.txt +0 -6
- package/prompts/_fixtures/golden/implement-github.txt +0 -8
- package/prompts/_fixtures/golden/learn-docs.txt +0 -8
- package/prompts/_fixtures/golden/learn-github.txt +0 -11
- package/prompts/_fixtures/golden/learn-linear.txt +0 -11
- package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
- package/prompts/_fixtures/golden/learn-other.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
- package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
- package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
|
@@ -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.
|
|
@@ -29,6 +30,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
29
30
|
import { join } from "node:path";
|
|
30
31
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { ensureRunScratch, scratchDir } from "../substrate/cache.ts";
|
|
33
|
+
import { registerPerkCommand } from "../substrate/command.ts";
|
|
32
34
|
import { type CiCheck, loadPerkConfig } from "../substrate/config.ts";
|
|
33
35
|
import { paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
34
36
|
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
@@ -93,7 +95,7 @@ export type CiScope = "run" | "confirm" | "refuse";
|
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
97
|
* Decide how to treat project-supplied CI. Pure (the load-bearing safety boundary):
|
|
96
|
-
* - `[
|
|
98
|
+
* - `[ci] trusted` (committed config), `--allow-project-ci`, or a per-session latch ⇒ "run"
|
|
97
99
|
* (trust runs on EVERY surface, overriding the headless refuse below)
|
|
98
100
|
* - else with UI ⇒ "confirm" (ask the human)
|
|
99
101
|
* - else (headless, no trust/flag) ⇒ "refuse" (fail closed)
|
|
@@ -163,7 +165,9 @@ export async function runOneCheck(
|
|
|
163
165
|
writeError = err instanceof Error ? err.message : String(err);
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
|
|
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");
|
|
167
171
|
return {
|
|
168
172
|
name,
|
|
169
173
|
command,
|
|
@@ -367,7 +371,7 @@ export function renderCiProse(report: CiReport): string {
|
|
|
367
371
|
);
|
|
368
372
|
}
|
|
369
373
|
if (report.error_type === "no_checks_configured") {
|
|
370
|
-
return "No CI checks configured ([[ci]] in .
|
|
374
|
+
return "No CI checks configured ([[ci.checks]] in .perk/config.toml is empty). Nothing to run.";
|
|
371
375
|
}
|
|
372
376
|
if (report.error_type === "unknown_check") {
|
|
373
377
|
return `perk CI: ${report.error}`;
|
|
@@ -397,6 +401,9 @@ export function renderCiProse(report: CiReport): string {
|
|
|
397
401
|
lines.push(c.shown || "(no output captured)");
|
|
398
402
|
lines.push("</untrusted_ci_output>");
|
|
399
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.
|
|
400
407
|
return capForModel(lines.join("\n"), DEFAULT_MODEL_VISIBLE_CAP).shown;
|
|
401
408
|
}
|
|
402
409
|
|
|
@@ -432,8 +439,9 @@ interface ApprovalLatch {
|
|
|
432
439
|
}
|
|
433
440
|
|
|
434
441
|
/**
|
|
435
|
-
* The single `run_ci`/`/ci` implementation. Loads `[ci]`, scopes the run (the
|
|
436
|
-
* 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.
|
|
437
445
|
*/
|
|
438
446
|
async function runCiImpl(
|
|
439
447
|
pi: ExtensionAPI,
|
|
@@ -443,7 +451,7 @@ async function runCiImpl(
|
|
|
443
451
|
deps: RunCiDeps = {},
|
|
444
452
|
): Promise<CiResult> {
|
|
445
453
|
const cfg = loadPerkConfig(ctx.cwd);
|
|
446
|
-
const checks: CiCheck[] = cfg.ci;
|
|
454
|
+
const checks: CiCheck[] = cfg.ci.checks;
|
|
447
455
|
const wrap = (report: CiReport): CiResult => ({
|
|
448
456
|
content: [{ type: "text", text: renderCiProse(report) }],
|
|
449
457
|
details: report,
|
|
@@ -455,7 +463,7 @@ async function runCiImpl(
|
|
|
455
463
|
if (checks.length > 0) {
|
|
456
464
|
const decideScope = deps.decideScope ?? decideCiScope;
|
|
457
465
|
const allowFlag = pi.getFlag("allow-project-ci") === true;
|
|
458
|
-
const trusted = cfg.
|
|
466
|
+
const trusted = cfg.ci.trusted;
|
|
459
467
|
const scope = decideScope({ hasUI: ctx.hasUI, allowFlag, approved: latch.approved, trusted });
|
|
460
468
|
|
|
461
469
|
if (scope === "refuse") {
|
|
@@ -506,7 +514,7 @@ async function runCiImpl(
|
|
|
506
514
|
const TOOL_GUIDELINES = [
|
|
507
515
|
"run_ci RUNS the configured CI checks and REPORTS results — it never edits, fixes, or loops.",
|
|
508
516
|
"Analyze any failure yourself, fix it in your own turn, then call run_ci again to re-verify.",
|
|
509
|
-
"
|
|
517
|
+
"Pass run_ci a single configured check name to run just that check; omit it to run all checks in declared order.",
|
|
510
518
|
"You own the Run→Report→Fix→Verify loop; run_ci is a stateless oracle, not an auto-fixer.",
|
|
511
519
|
];
|
|
512
520
|
|
|
@@ -565,7 +573,7 @@ export function registerCiExecutor(pi: ExtensionAPI): void {
|
|
|
565
573
|
},
|
|
566
574
|
});
|
|
567
575
|
|
|
568
|
-
pi
|
|
576
|
+
registerPerkCommand(pi, "ci", {
|
|
569
577
|
description: "Run the project's configured CI checks and report results (never auto-fixes).",
|
|
570
578
|
handler: async (args, ctx) => {
|
|
571
579
|
const check = args.trim() === "" ? undefined : args.trim();
|
|
@@ -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,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
runColdDoor,
|
|
16
19
|
stringField,
|
|
17
20
|
} from "../substrate/coldDoor.ts";
|
|
21
|
+
import { registerPerkCommand } from "../substrate/command.ts";
|
|
18
22
|
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
19
23
|
import { report } from "../surfaces/report.ts";
|
|
20
24
|
|
|
@@ -36,7 +40,8 @@ export interface LearnConsumeUpdate {
|
|
|
36
40
|
skipped_reason: string | null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
/** 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). */
|
|
40
45
|
export interface LandOk {
|
|
41
46
|
pr: { number: number; state: string };
|
|
42
47
|
branch?: string;
|
|
@@ -50,11 +55,13 @@ export interface LandOk {
|
|
|
50
55
|
export type LandResult = Result<LandOk>;
|
|
51
56
|
export type LandDetails = LandResult["details"];
|
|
52
57
|
|
|
53
|
-
/** 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. */
|
|
54
60
|
interface LandPayload {
|
|
55
61
|
pr: { number: number; state: string };
|
|
56
62
|
branch?: string;
|
|
57
63
|
issue?: string;
|
|
64
|
+
pending_learn: boolean;
|
|
58
65
|
objective?: ObjectiveLandUpdate;
|
|
59
66
|
learn?: LearnConsumeUpdate;
|
|
60
67
|
}
|
|
@@ -107,6 +114,10 @@ function decodeLand(payload: ColdJson): LandPayload | null {
|
|
|
107
114
|
pr: { number, state },
|
|
108
115
|
branch: stringField(payload, "branch"),
|
|
109
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,
|
|
110
121
|
objective: decodeObjective(payload),
|
|
111
122
|
learn: decodeLearn(payload),
|
|
112
123
|
};
|
|
@@ -114,7 +125,9 @@ function decodeLand(payload: ColdJson): LandPayload | null {
|
|
|
114
125
|
|
|
115
126
|
/**
|
|
116
127
|
* The single land implementation both surfaces call. Delegates the merge to the Python cold door,
|
|
117
|
-
* 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).
|
|
118
131
|
*/
|
|
119
132
|
export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
|
|
120
133
|
const fail = failFor(ctx, "land");
|
|
@@ -125,10 +138,16 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
|
|
|
125
138
|
});
|
|
126
139
|
if (!r.ok) return fail(r.message, r.errorType);
|
|
127
140
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
130
145
|
|
|
131
|
-
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
|
+
];
|
|
132
151
|
const obj = r.data.objective;
|
|
133
152
|
if (obj?.nodes_marked.length && obj.id !== null) {
|
|
134
153
|
// The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
|
|
@@ -155,7 +174,11 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
|
|
|
155
174
|
lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
|
|
156
175
|
}
|
|
157
176
|
|
|
158
|
-
return ok(
|
|
177
|
+
return ok(
|
|
178
|
+
lines.join("\n"),
|
|
179
|
+
{ ...r.data, pending_learn: r.data.pending_learn },
|
|
180
|
+
{ terminate: true },
|
|
181
|
+
);
|
|
159
182
|
}
|
|
160
183
|
|
|
161
184
|
/**
|
|
@@ -208,7 +231,7 @@ export function registerLand(pi: ExtensionAPI): void {
|
|
|
208
231
|
},
|
|
209
232
|
});
|
|
210
233
|
|
|
211
|
-
pi
|
|
234
|
+
registerPerkCommand(pi, "land", {
|
|
212
235
|
description: "Merge the active plan's PR and set pending-learn (submit → land).",
|
|
213
236
|
handler: async (_args, ctx) => {
|
|
214
237
|
const result = await landPr(pi, ctx);
|