@mgiles/perk 1.0.1 → 1.1.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.
Files changed (120) hide show
  1. package/README.md +7 -21
  2. package/extension/checkpoints/checkpoints.ts +2 -1
  3. package/extension/doors/address.ts +2 -1
  4. package/extension/doors/ciExecutor.ts +3 -2
  5. package/extension/doors/land.ts +2 -1
  6. package/extension/doors/learn.ts +239 -33
  7. package/extension/doors/learnCode.ts +100 -0
  8. package/extension/doors/learnDocs.ts +4 -3
  9. package/extension/doors/lifecycleGates.ts +2 -1
  10. package/extension/doors/prReview.ts +20 -35
  11. package/extension/doors/prReviewLocal.ts +229 -0
  12. package/extension/doors/ready.ts +2 -1
  13. package/extension/doors/selfcheck.ts +2 -1
  14. package/extension/doors/submit.ts +17 -19
  15. package/extension/factories/implementHere.ts +116 -0
  16. package/extension/factories/objective.ts +2 -1
  17. package/extension/factories/objectivePlan.ts +4 -24
  18. package/extension/factories/objectiveSave.ts +5 -15
  19. package/extension/factories/planMode.ts +5 -1
  20. package/extension/factories/planReview.ts +103 -11
  21. package/extension/factories/planSave.ts +16 -1
  22. package/extension/index.ts +42 -3
  23. package/extension/substrate/bindings.ts +1 -1
  24. package/extension/substrate/cache.ts +2 -2
  25. package/extension/substrate/command.ts +25 -0
  26. package/extension/substrate/config.ts +12 -13
  27. package/extension/substrate/consoleCapture.ts +90 -0
  28. package/extension/substrate/git.ts +34 -0
  29. package/extension/substrate/miniJinja.ts +480 -0
  30. package/extension/substrate/paths.ts +38 -0
  31. package/extension/substrate/prompts.ts +15 -24
  32. package/extension/substrate/providers.ts +1 -1
  33. package/extension/substrate/sessionData.ts +1 -1
  34. package/extension/substrate/sessionPointers.ts +155 -0
  35. package/extension/substrate/toolGating.ts +8 -0
  36. package/extension/surfaces/surfaces.ts +10 -3
  37. package/extension/worker/worker.ts +111 -25
  38. package/extension/workerMain.ts +5 -3
  39. package/package.json +1 -5
  40. package/prompts/README.md +56 -5
  41. package/prompts/_fixtures/cases.yaml +52 -131
  42. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  43. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  44. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  45. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  46. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  47. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  48. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  49. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  50. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  51. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  52. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  53. package/prompts/_fixtures/live.yaml +281 -0
  54. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  55. package/prompts/_fixtures/templates/cond_if.md +7 -0
  56. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  57. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  58. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  59. package/prompts/_fixtures/templates/trim_block.md +5 -0
  60. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  61. package/prompts/stages/conflict-resolution.md +4 -0
  62. package/prompts/stages/learn-code.md +8 -0
  63. package/prompts/stages/learn-docs.md +7 -6
  64. package/prompts/stages/learn-orchestrate.md +6 -0
  65. package/prompts/stages/learn.md +1 -1
  66. package/prompts/stages/objective-author/adopt.md +12 -0
  67. package/prompts/stages/objective-author/file.md +9 -0
  68. package/prompts/stages/objective-author/seed.md +9 -0
  69. package/prompts/stages/objective-plan/seed.md +2 -1
  70. package/prompts/stages/objective-reconcile.md +7 -0
  71. package/prompts/stages/objective-replan.md +14 -0
  72. package/prompts/stages/objective-save.md +9 -0
  73. package/prompts/stages/plan-from/adopt.md +10 -0
  74. package/prompts/stages/plan-from/file.md +9 -0
  75. package/prompts/stages/pr-review.md +6 -0
  76. package/prompts/stages/replan.md +13 -0
  77. package/prompts/stages/skills/create-from.md +15 -0
  78. package/prompts/stages/skills/create.md +9 -0
  79. package/prompts/stages/skills/refine.md +9 -0
  80. package/shared/README.md +7 -1
  81. package/shared/bindings.yaml +12 -0
  82. package/shared/contracts-history.md +167 -0
  83. package/shared/contracts.md +1307 -342
  84. package/shared/registry.yaml +3 -3
  85. package/shared/schemas/contracts/bindings.schema.json +38 -0
  86. package/shared/schemas/contracts/providers.schema.json +89 -0
  87. package/shared/schemas/contracts/registry.schema.json +98 -0
  88. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  89. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  90. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  91. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  92. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  93. package/shared/schemas/outputs/init-report.schema.json +419 -0
  94. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  95. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  96. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  97. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  98. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  99. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  100. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  101. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  102. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  103. package/prompts/_fixtures/golden/address-action.txt +0 -10
  104. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  105. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  106. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  107. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  108. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  109. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  110. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  111. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  112. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  113. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  114. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  115. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  116. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  117. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  118. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  119. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  120. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -31,6 +31,7 @@
31
31
 
32
32
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
33
  import { Key } from "@earendil-works/pi-tui";
34
+ import { registerPerkCommand } from "../substrate/command.ts";
34
35
  import { loadPerkConfig } from "../substrate/config.ts";
35
36
  import {
36
37
  loadProviders,
@@ -82,6 +83,9 @@ When the plan is decision-complete, request a human review:
82
83
  - If the review is APPROVED: the plan is auto-saved and the session leaves read-only. Relay the
83
84
  save outcome — do NOT re-dump the plan as a final message and do NOT tell the user to run
84
85
  /plan-save.
86
+ - If the review returns IMPLEMENT HERE: the human chose to implement without saving an issue —
87
+ the session is read-write; implement the plan now in this checkout (edits only; leave git
88
+ gestures to the user).
85
89
  - If plan_review reports it was skipped or unavailable (headless, dismissed, no surface): present
86
90
  the complete plan as your final message; the human runs /plan-save (the manual failsafe).`;
87
91
 
@@ -150,7 +154,7 @@ export function registerPlanMode(pi: ExtensionAPI, gating: ToolGating): void {
150
154
  }
151
155
  }
152
156
 
153
- pi.registerCommand("plan", {
157
+ registerPerkCommand(pi, "plan", {
154
158
  description: "Toggle perk plan mode (read-only exploration + plan authoring).",
155
159
  handler: async (_args, ctx) => toggle(ctx),
156
160
  });
@@ -6,8 +6,10 @@
6
6
  // (`runFirstPartyReview`): display the draft in pi's built-in `ctx.ui.editor` dialog (scrollable,
7
7
  // Ctrl+G opens the user's external $EDITOR), write optional human edits back to the draft via
8
8
  // `writePlanDraft` BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes — a failed
9
- // write-back ABORTS the review fail-open, nothing saved), then a 3-option approve/deny/skip
10
- // `ctx.ui.select` verdict, with deny feedback via a second editor dialog.
9
+ // write-back ABORTS the review fail-open, nothing saved), then an approve/deny/skip
10
+ // `ctx.ui.select` verdict on the plan arm with a 4th "Implement here — no issue saved" option
11
+ // (§8.23; suppressed in objective-node planning sessions) — with deny feedback via a second
12
+ // editor dialog.
11
13
  //
12
14
  // REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
13
15
  // read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
@@ -51,8 +53,10 @@ import {
51
53
  import type { ToolGating } from "../substrate/toolGating.ts";
52
54
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
53
55
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
56
+ import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
54
57
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
55
58
  import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
59
+ import { readNodeClaim } from "./objectivePlan.ts";
56
60
  import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
57
61
  import { writePlanDraft } from "./planDraft.ts";
58
62
  import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
@@ -62,12 +66,15 @@ import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./pla
62
66
  /**
63
67
  * The review outcome a backend produces, mapped into a tool result below (also `details.status`).
64
68
  * The `dismissed` arm is FIRST-PARTY ONLY (Esc anywhere = fail-open skip; the plannotator bridge
65
- * never produces it).
69
+ * never produces it). The `implement-here` arm is first-party PLAN-arm only (the human chose the
70
+ * no-save exit — contracts.md §8.23); the plannotator bridge never produces it (its browser
71
+ * envelope returns only approve/deny) and the objective arm never offers it.
66
72
  */
67
73
  export type ReviewOutcome =
68
74
  | { status: "unavailable"; warning: string }
69
75
  | { status: "aborted" }
70
76
  | { status: "dismissed" }
77
+ | { status: "implement-here"; reviewId: string }
71
78
  | { status: "completed"; approved: boolean; feedback?: string; reviewId: string };
72
79
 
73
80
  interface ToolResult {
@@ -121,6 +128,20 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
121
128
  ],
122
129
  details: { status: "skipped", reason: "dismissed" },
123
130
  };
131
+ case "implement-here":
132
+ // Defensively unreachable: the execute path routes implement-here to implementHereResult
133
+ // FIRST (mirror the approved-first routing). Map to a skip shape rather than throwing.
134
+ return {
135
+ content: [
136
+ {
137
+ type: "text",
138
+ text:
139
+ "implement-here verdict received outside the execute path — nothing saved; " +
140
+ "present the complete plan to the user.",
141
+ },
142
+ ],
143
+ details: { status: "skipped", reason: "implement-here" },
144
+ };
124
145
  case "completed": {
125
146
  const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
126
147
  const text =
@@ -206,6 +227,36 @@ export function approvedSaveResult(
206
227
  };
207
228
  }
208
229
 
230
+ /**
231
+ * Map an IMPLEMENT-HERE review outcome + the `implementHereExit` outcome into the model-facing
232
+ * tool result (exported for the offline tests). NON-terminating on purpose — the model continues
233
+ * the turn and implements immediately. The text is the implement-here guidance; when the human
234
+ * edited the plan during review (`edited`), the final reviewed bytes are inlined so the model
235
+ * implements THOSE, not its stale in-context version (the draft write-back already happened
236
+ * pre-verdict). Nothing is saved: no issue, no plan-ref, the draft artifact intact (§8.23).
237
+ */
238
+ export function implementHereResult(
239
+ outcome: Extract<ReviewOutcome, { status: "implement-here" }>,
240
+ exit: { gateExited: boolean },
241
+ opts: { cwd: string; plan: string; edited: boolean },
242
+ ): ToolResult {
243
+ return {
244
+ content: [
245
+ {
246
+ type: "text",
247
+ text: implementHereGuidance(opts.cwd, { editedPlan: opts.edited ? opts.plan : undefined }),
248
+ },
249
+ ],
250
+ details: {
251
+ status: "implement-here",
252
+ saved: false,
253
+ gateExited: exit.gateExited,
254
+ reviewId: outcome.reviewId,
255
+ ...(opts.edited ? { edited: true } : {}),
256
+ },
257
+ };
258
+ }
259
+
209
260
  // ----------------------------------------------------------------- the first-party review core
210
261
 
211
262
  /** The minimal structural `ctx.ui` subset the first-party review needs (the askUser.ts recipe). */
@@ -222,6 +273,8 @@ export interface PlanReviewUI {
222
273
  const VERDICT_APPROVE = "Approve — auto-save to GitHub";
223
274
  const VERDICT_DENY = "Deny — send feedback for revision";
224
275
  const VERDICT_SKIP = "Skip — decide later (manual /plan-save)";
276
+ /** The optional 4th verdict (plan arm only): the no-save implement-here exit (§8.23). */
277
+ const VERDICT_IMPLEMENT_HERE = "Implement here — no issue saved";
225
278
 
226
279
  const REVIEW_EDITOR_TITLE =
227
280
  "Plan review — Enter: continue to verdict · Esc: skip · Ctrl+G: $EDITOR";
@@ -238,7 +291,10 @@ const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
238
291
  * AbortSignal — `signal?.aborted` is checked before each dialog (the aborted arm wins).
239
292
  *
240
293
  * Presentation options (defaults preserve the plan-path behavior byte-for-byte):
241
- * `editorTitle`/`verdicts` swap the displayed strings; `viewOnly: true` skips the write-back
294
+ * `editorTitle`/`verdicts` swap the displayed strings; `verdicts.implementHere`, when present,
295
+ * makes the verdict select 4 options — approve, implement-here, deny, skip (implement-here sits
296
+ * adjacent to approve: both are "accept the plan" outcomes) — and selecting it returns the
297
+ * `implement-here` outcome arm; `viewOnly: true` skips the write-back
242
298
  * branch entirely — the editor output is used only for Esc/dismissed detection, `plan` is
243
299
  * returned unchanged and `edited` stays false (deny+feedback is the change channel).
244
300
  */
@@ -249,7 +305,7 @@ export async function runFirstPartyReview(args: {
249
305
  writeDraft(plan: string): boolean;
250
306
  signal?: AbortSignal;
251
307
  editorTitle?: string;
252
- verdicts?: { approve: string; deny: string; skip: string };
308
+ verdicts?: { approve: string; deny: string; skip: string; implementHere?: string };
253
309
  viewOnly?: boolean;
254
310
  }): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
255
311
  const { ui, writeDraft, signal } = args;
@@ -291,15 +347,18 @@ export async function runFirstPartyReview(args: {
291
347
  }
292
348
 
293
349
  if (signal?.aborted) return result({ status: "aborted" });
294
- const verdict = await ui.select(
295
- "Plan review verdict",
296
- [verdicts.approve, verdicts.deny, verdicts.skip],
297
- { signal },
298
- );
350
+ const options =
351
+ verdicts.implementHere === undefined
352
+ ? [verdicts.approve, verdicts.deny, verdicts.skip]
353
+ : [verdicts.approve, verdicts.implementHere, verdicts.deny, verdicts.skip];
354
+ const verdict = await ui.select("Plan review verdict", options, { signal });
299
355
  if (signal?.aborted) return result({ status: "aborted" });
300
356
  if (verdict === verdicts.approve) {
301
357
  return result({ status: "completed", approved: true, reviewId: randomUUID() });
302
358
  }
359
+ if (verdicts.implementHere !== undefined && verdict === verdicts.implementHere) {
360
+ return result({ status: "implement-here", reviewId: randomUUID() });
361
+ }
303
362
  if (verdict === verdicts.deny) {
304
363
  const feedback = await ui.editor(DENY_FEEDBACK_TITLE, "");
305
364
  if (signal?.aborted) return result({ status: "aborted" });
@@ -366,6 +425,20 @@ export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult
366
425
  ],
367
426
  details: { status: "skipped", reason: "dismissed", subject: "objective" },
368
427
  };
428
+ case "implement-here":
429
+ // Defensively unreachable twice over: the objective arm never offers the verdict, and the
430
+ // execute path routes implement-here first. Map to a skip shape rather than throwing.
431
+ return {
432
+ content: [
433
+ {
434
+ type: "text",
435
+ text:
436
+ "implement-here verdict received on the objective path — nothing saved; present " +
437
+ "the complete objective + structured roadmap to the user.",
438
+ },
439
+ ],
440
+ details: { status: "skipped", reason: "implement-here", subject: "objective" },
441
+ };
369
442
  case "completed": {
370
443
  const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
371
444
  return {
@@ -577,17 +650,36 @@ export async function executePlanReview(
577
650
  if (isPlannotatorPlanSelected(ctx.cwd)) {
578
651
  outcome = await bridge.review(src.plan, sig);
579
652
  } else {
653
+ // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
654
+ // objective-node planning session — a node-linked plan must save (the node advance and
655
+ // backlink depend on it), so the claim suppresses it back to the 3-option select.
580
656
  const fp = await runFirstPartyReview({
581
657
  ui: ctx.ui,
582
658
  plan: src.plan,
583
659
  writeDraft: (text) => writePlanDraft(pi, ctx, text).details.ok,
584
660
  signal: sig,
661
+ verdicts:
662
+ readNodeClaim(ctx) === null
663
+ ? {
664
+ approve: VERDICT_APPROVE,
665
+ deny: VERDICT_DENY,
666
+ skip: VERDICT_SKIP,
667
+ implementHere: VERDICT_IMPLEMENT_HERE,
668
+ }
669
+ : undefined,
585
670
  });
586
671
  outcome = fp.outcome;
587
672
  reviewedPlan = fp.plan;
588
673
  edited = fp.edited;
589
674
  }
590
- // 5. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
675
+ // 5. IMPLEMENT-HERE (first-party only) routes before the generic mapper (mirror the
676
+ // approved-first split): gate exit WITHOUT save through the implementHereExit seam → a
677
+ // NON-terminating result carrying the implement-now guidance.
678
+ if (outcome.status === "implement-here") {
679
+ const exit = implementHereExit(ctx, gating);
680
+ return implementHereResult(outcome, exit, { cwd: ctx.cwd, plan: reviewedPlan, edited });
681
+ }
682
+ // 6. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
591
683
  // gate exit → terminating result); everything else maps via reviewOutcomeResult.
592
684
  if (outcome.status === "completed" && outcome.approved) {
593
685
  const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
@@ -34,8 +34,10 @@ import {
34
34
  runColdDoor,
35
35
  stringField,
36
36
  } from "../substrate/coldDoor.ts";
37
+ import { registerPerkCommand } from "../substrate/command.ts";
37
38
  import { failFor, ok, type Result } from "../substrate/result.ts";
38
39
  import { readSessionArtifact, type SessionDataCtx } from "../substrate/sessionData.ts";
40
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
39
41
  import type { ToolGating } from "../substrate/toolGating.ts";
40
42
  import { idArrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
41
43
  import {
@@ -312,6 +314,19 @@ export async function savePlan(
312
314
  });
313
315
  if (!r.ok) return fail(r.message, r.errorType);
314
316
 
317
+ // Capture the planning session pointer (contracts.md §8.35): this planning run self-keys by its
318
+ // own run_id into the shared main checkout, so a later/other session can resolve it cross-run.
319
+ // Best-effort + non-fatal (the carrier warns + returns false; a successful save must stand).
320
+ // Covers /plan-save, the plan_save tool, and approvalSave (all flow through savePlan).
321
+ captureSessionPointer({
322
+ cwd: ctx.cwd,
323
+ runId,
324
+ klass: "planning",
325
+ site: "main",
326
+ // Optional-chained: best-effort, and some side-session fakes have no getSessionFile.
327
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
328
+ });
329
+
315
330
  // Link the live session (turn-3 D4): append iff the rebuilt ref differs, with a strict read-back.
316
331
  const ref = r.data.plan_ref;
317
332
  if (!planRefsEqual(rebuildWorkflowState(branch()).active_plan_ref ?? null, ref)) {
@@ -552,7 +567,7 @@ export function registerPlanSave(pi: ExtensionAPI, gating: ToolGating): void {
552
567
  },
553
568
  });
554
569
 
555
- pi.registerCommand("plan-save", {
570
+ registerPerkCommand(pi, "plan-save", {
556
571
  description:
557
572
  "Save the latest proposed plan to GitHub — the manual failsafe for the approval→save flow " +
558
573
  "(the read-only → read-write boundary).",
@@ -16,12 +16,15 @@ import { registerAskUser } from "./doors/askUser.ts";
16
16
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
17
  import { registerLand } from "./doors/land.ts";
18
18
  import { registerLearn } from "./doors/learn.ts";
19
+ import { registerLearnCode } from "./doors/learnCode.ts";
19
20
  import { registerLearnDocs } from "./doors/learnDocs.ts";
20
21
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
21
22
  import { registerPrReview } from "./doors/prReview.ts";
23
+ import { registerPrReviewLocal } from "./doors/prReviewLocal.ts";
22
24
  import { registerReady } from "./doors/ready.ts";
23
25
  import { registerSelfcheck } from "./doors/selfcheck.ts";
24
26
  import { registerSubmit } from "./doors/submit.ts";
27
+ import { registerImplementHere } from "./factories/implementHere.ts";
25
28
  import { registerObjective } from "./factories/objective.ts";
26
29
  import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
27
30
  import { registerObjectiveDraft } from "./factories/objectiveDraft.ts";
@@ -38,10 +41,12 @@ import {
38
41
  readHandoff,
39
42
  readPlanRef,
40
43
  setMarker,
44
+ workflowDir,
41
45
  } from "./substrate/cache.ts";
42
46
  import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
43
47
  import { perkVersion, sharedDir } from "./substrate/resources.ts";
44
48
  import { mintRunId } from "./substrate/runId.ts";
49
+ import { captureSessionPointer } from "./substrate/sessionPointers.ts";
45
50
  import { registerToolGating } from "./substrate/toolGating.ts";
46
51
  import {
47
52
  appendWorkflowState,
@@ -69,7 +74,7 @@ function writeT3Sentinel(
69
74
  runMode: string | null,
70
75
  ): void {
71
76
  try {
72
- const dir = join(cwd, ".pi", "workflow");
77
+ const dir = workflowDir(cwd);
73
78
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
74
79
  writeFileSync(
75
80
  join(dir, ".perk-t3.json"),
@@ -290,6 +295,24 @@ export default function (pi: ExtensionAPI) {
290
295
  resolved = { ...resolved, active_plan_ref: linked };
291
296
  }
292
297
 
298
+ // Implementation session pointer (contracts.md §8.35): an implement session self-keys its own
299
+ // session file into the shared main checkout so a later/other session resolves it cross-run.
300
+ // The headless worker's inner session lands here too (.main); driveStage records the matching
301
+ // .worker. A forked implement session inherits the parent's launched stage + threads the
302
+ // inherited parent session id as fork provenance. Best-effort + non-fatal (carrier warns).
303
+ const implStage =
304
+ runStage ?? (decision.action === "fork" ? (decision.state.stage ?? null) : null);
305
+ if (resolved.run_id && implStage === "implement") {
306
+ captureSessionPointer({
307
+ cwd: ctx.cwd,
308
+ runId: resolved.run_id,
309
+ klass: "implementation",
310
+ site: "main",
311
+ sessionFile,
312
+ parentSessionId: decision.action === "fork" ? (decision.state.pi_session_id ?? null) : null,
313
+ });
314
+ }
315
+
293
316
  // Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
294
317
  // leave the gate as-is (a failed sync never opens it).
295
318
  try {
@@ -327,6 +350,7 @@ export default function (pi: ExtensionAPI) {
327
350
  identity: `perk v${version}`,
328
351
  status: perkStatus,
329
352
  getModelId: () => ctx.model?.id ?? null,
353
+ getThinkingLevel: () => (ctx.model ? pi.getThinkingLevel() : null),
330
354
  getContext: () => {
331
355
  const usage = ctx.getContextUsage();
332
356
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
@@ -337,7 +361,7 @@ export default function (pi: ExtensionAPI) {
337
361
 
338
362
  if (process.env.PERK_SELFCHECK) {
339
363
  try {
340
- const dir = join(ctx.cwd, ".pi", "workflow");
364
+ const dir = workflowDir(ctx.cwd);
341
365
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
342
366
  // The gate sentinel (unchanged — those gates parse this line).
343
367
  writeFileSync(
@@ -371,6 +395,11 @@ export default function (pi: ExtensionAPI) {
371
395
  // a successful command-path save exits read-only mode (the read-only → read-write boundary).
372
396
  registerPlanSave(pi, gating);
373
397
 
398
+ // The `/implement-here` command: the human-only no-save exit from plan mode (§8.23) —
399
+ // implement the reviewed draft in-session, no issue created. Composes the gate through the
400
+ // implementHereExit seam; no model tool is registered (machine-unreachable by construction).
401
+ registerImplementHere(pi, gating);
402
+
374
403
  // The `plan_draft` working-draft file tool. Registered in the factory so it
375
404
  // exists before the gate snapshots tools; its name is in READ_ONLY_TOOLS (the structural
376
405
  // session-data carve-out), so it survives plan mode.
@@ -406,6 +435,10 @@ export default function (pi: ExtensionAPI) {
406
435
  // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
407
436
  registerPrReview(pi);
408
437
 
438
+ // The warm `/pr-review-local` command: open the plannotator browser code review on the active
439
+ // PR (URL filled in implicitly) by bridging to plannotator's published `code-review` event.
440
+ registerPrReviewLocal(pi);
441
+
409
442
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
410
443
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
411
444
  registerCiExecutor(pi);
@@ -447,9 +480,15 @@ export default function (pi: ExtensionAPI) {
447
480
  // factory guidance so the model authors a docs/learned consolidation plan (no model tool).
448
481
  registerLearnDocs(pi);
449
482
 
483
+ // The learn-code plan factory's warm surface: the `/learn-code` command gathers pre-stamped
484
+ // SHOULD_BE_CODE perk:learn issues into an inbox (via the `perk learn code --gather` cold door)
485
+ // and injects the factory guidance so the model authors a code-routing plan (no model tool).
486
+ registerLearnCode(pi);
487
+
450
488
  // Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
451
489
  // the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
452
- // `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` guidance.
490
+ // `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` +
491
+ // `/learn-code` guidance.
453
492
  registerBindingDelivery(pi);
454
493
 
455
494
  // `/perk-selfcheck` — the session-wiring verifier (turned from a liveness ping into a real check
@@ -74,7 +74,7 @@ export function loadDefaultBindings(): SkillBinding[] {
74
74
  }
75
75
 
76
76
  /**
77
- * Parse `.pi/perk.toml` `[[bindings]]` rows (string tables) into `SkillBinding`s. Tolerant like
77
+ * Parse `.perk/config.toml` `[[bindings]]` rows (string tables) into `SkillBinding`s. Tolerant like
78
78
  * the YAML reader: absent/ill-typed fields become empty strings so the *resolver* reports them.
79
79
  */
80
80
  export function parseUserBindings(rows: Array<Record<string, string>>): SkillBinding[] {
@@ -1,4 +1,4 @@
1
- // `.pi/workflow/` cache-tier I/O — the TS twin of perk/state/cache.py (contracts.md §8.1).
1
+ // `.perk/workflow/` cache-tier I/O — the TS twin of perk/state/cache.py (contracts.md §8.1).
2
2
  //
3
3
  // Both planes read and write the SAME files; the cross-plane contract is the *files*, not a
4
4
  // shared module. State-tiering primitives only — no workflow semantics. Imports use no
@@ -18,7 +18,7 @@ export interface Handoff {
18
18
  }
19
19
 
20
20
  export function workflowDir(cwd: string): string {
21
- return join(cwd, ".pi", "workflow");
21
+ return join(cwd, ".perk", "workflow");
22
22
  }
23
23
 
24
24
  // --- handoff -----------------------------------------------------------------------------
@@ -0,0 +1,25 @@
1
+ // The single chokepoint that gives every perk command a uniform, immediate "running…"
2
+ // acknowledgement at entry. pi does not echo the invoked command and handlers only report() at the
3
+ // end, so without this a command's async work (cold-door subprocess calls, GitHub round-trips) is
4
+ // dead air between Enter and completion. registerPerkCommand wraps the handler to emit one transient
5
+ // entry toast through the headless-safe report() seam (no cleanup state, headless-fail-safe for
6
+ // free) before awaiting the original handler. The toast fires synchronously before the first await,
7
+ // so it lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does
8
+ // not try/catch, so errors propagate exactly as before.
9
+
10
+ import type { ExtensionAPI, RegisteredCommand } from "@earendil-works/pi-coding-agent";
11
+ import { report } from "../surfaces/report.ts";
12
+
13
+ export function registerPerkCommand(
14
+ pi: ExtensionAPI,
15
+ name: string,
16
+ options: Omit<RegisteredCommand, "name" | "sourceInfo">,
17
+ ): void {
18
+ pi.registerCommand(name, {
19
+ ...options,
20
+ handler: async (args, ctx) => {
21
+ report(ctx, name, "info", "running…");
22
+ await options.handler(args, ctx);
23
+ },
24
+ });
25
+ }
@@ -1,5 +1,5 @@
1
1
  // The minimal TS config port. Mirrors `perk/substrate/config.py`'s overlay: read
2
- // `.pi/perk.toml` (committed) overlaid by `.pi/perk.local.toml` (gitignored, local wins). The only
2
+ // `.perk/config.toml` (committed) overlaid by `.perk/local.toml` (gitignored, local wins). The only
3
3
  // setting consumed today is an optional `[workflow]` plan-authoring addendum, appended into the
4
4
  // `perk:plan-context` injection (extension/factories/planMode.ts) when present.
5
5
  //
@@ -10,11 +10,8 @@
10
10
  // Dynamic `resources_discover` skill/prompt contribution is a flagged follow-up, not built here.
11
11
 
12
12
  import { existsSync, readFileSync } from "node:fs";
13
- import { join } from "node:path";
14
13
  import { parseUserBindings, type SkillBinding } from "./bindings.ts";
15
-
16
- const CONFIG_FILENAME = "perk.toml";
17
- const LOCAL_CONFIG_FILENAME = "perk.local.toml";
14
+ import { configFile, localConfigFile } from "./paths.ts";
18
15
 
19
16
  /**
20
17
  * One configured CI check (a `[[ci]]` array-of-tables row). `name`/`command` are required
@@ -39,7 +36,8 @@ export interface PerkConfig {
39
36
  ci: CiCheck[];
40
37
  /**
41
38
  * The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
42
- * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`). Each configured
39
+ * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
40
+ * `learn-analyst`). Each configured
43
41
  * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
44
42
  * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
45
43
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
@@ -51,6 +49,7 @@ export interface PerkConfig {
51
49
  "review-classifier"?: string;
52
50
  "objective-explorer"?: string;
53
51
  "conflict-resolver"?: string;
52
+ "learn-analyst"?: string;
54
53
  };
55
54
  /**
56
55
  * Optional `[objective] compact_threshold` — the context-usage fraction (0,1] that triggers
@@ -210,12 +209,11 @@ function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
210
209
  return { tables, arrays };
211
210
  }
212
211
 
213
- /** Load `.pi/perk.toml` overlaid by `.pi/perk.local.toml` from `cwd` (mirror of perk/substrate/config.py). */
212
+ /** Load `.perk/config.toml` overlaid by `.perk/local.toml` from `cwd` (mirror of perk/substrate/config.py). */
214
213
  export function loadPerkConfig(cwd: string): PerkConfig {
215
- const piDir = join(cwd, ".pi");
216
214
  let merged: TomlSubset = emptySubset();
217
- for (const name of [CONFIG_FILENAME, LOCAL_CONFIG_FILENAME]) {
218
- merged = overlay(merged, readTomlFile(join(piDir, name)));
215
+ for (const file of [configFile(cwd), localConfigFile(cwd)]) {
216
+ merged = overlay(merged, readTomlFile(file));
219
217
  }
220
218
 
221
219
  const planAuthoring = merged.tables.workflow?.plan_authoring;
@@ -263,6 +261,7 @@ const SUBAGENT_KEYS = [
263
261
  "review-classifier",
264
262
  "objective-explorer",
265
263
  "conflict-resolver",
264
+ "learn-analyst",
266
265
  ] as const;
267
266
 
268
267
  /**
@@ -320,16 +319,16 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
320
319
  /**
321
320
  * The fail-safe TS mirror of the issue-backend selection.
322
321
  *
323
- * Reads ONLY committed `.pi/perk.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
322
+ * Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
324
323
  * the Python committed-only read (the backend decides where canonical durable state is written;
325
- * a per-user `perk.local.toml` override would fragment the canonical store). Python
324
+ * a per-user `.perk/local.toml` override would fragment the canonical store). Python
326
325
  * (`perk/backends/issues.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and **raises** on
327
326
  * "linear"/unknown; this mirror is fail-safe (absence/unknown/any error → `"github"`) because
328
327
  * the TS plane only renders prompts — it never writes canonical issues.
329
328
  */
330
329
  export function resolveIssueBackendId(cwd: string): IssueBackendId {
331
330
  try {
332
- const committed = readTomlFile(join(cwd, ".pi", CONFIG_FILENAME));
331
+ const committed = readTomlFile(configFile(cwd));
333
332
  const backend = committed.tables.issues?.backend;
334
333
  if (backend === "github" || backend === "linear") return backend;
335
334
  return GITHUB_ISSUE_BACKEND_ID;
@@ -0,0 +1,90 @@
1
+ // A small, pure swap of the global `console.error` so in-process chatter can be re-routed through a
2
+ // TUI-safe sink while a background task runs. plannotator's browser code-review setup writes
3
+ // progress straight to `process.stderr` via plain `console.error(...)` (fetching the PR, creating
4
+ // the local checkout, …); those raw writes bypass pi's managed rendering and paint over the input
5
+ // box. While the request is in flight we install a replacement that forwards each captured line to
6
+ // an injected sink (perk routes it through `report()`), then restore the original.
7
+ //
8
+ // Restore is debounce-driven: setup emits a burst of lines then goes quiet once the browser is up,
9
+ // so we restore after `quietMs` with no new line (self-adjusts to the variable several-second
10
+ // setup, no fixed-duration guess). A `finally` backstop in the caller restores too; `restore()` is
11
+ // idempotent and only reassigns `console.error` when our replacement is still installed, so an
12
+ // accidental overlap with a newer patcher is safe rather than clobbering. The replacement is inert
13
+ // once restored (a stale reference delegates to the original) and re-entrancy-safe (a console.error
14
+ // from inside the sink delegates to the original instead of recursing).
15
+
16
+ export type ConsoleErrorSink = (line: string) => void;
17
+
18
+ export interface ConsoleErrorInterceptor {
19
+ restore(): void;
20
+ }
21
+
22
+ interface InterceptOptions {
23
+ quietMs: number;
24
+ /** Injectable purely so the unit test drives a deterministic fake clock. */
25
+ schedule?: (fn: () => void, ms: number) => unknown;
26
+ clearScheduled?: (handle: unknown) => void;
27
+ }
28
+
29
+ /**
30
+ * Swap `console.error` so captured lines reach `sink` (joined with a space for multi-arg calls,
31
+ * mirroring plannotator's occasional 2-arg `console.error`). Returns an interceptor whose
32
+ * `restore()` reinstates the original. The original is also restored automatically once no captured
33
+ * line has arrived for `quietMs`.
34
+ */
35
+ export function interceptConsoleError(
36
+ sink: ConsoleErrorSink,
37
+ opts: InterceptOptions,
38
+ ): ConsoleErrorInterceptor {
39
+ const schedule = opts.schedule ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
40
+ const clearScheduled =
41
+ opts.clearScheduled ??
42
+ ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
43
+
44
+ const original = console.error;
45
+ let restored = false;
46
+ let active = false;
47
+ let handle: unknown;
48
+
49
+ const restore = (): void => {
50
+ if (handle !== undefined) {
51
+ clearScheduled(handle);
52
+ handle = undefined;
53
+ }
54
+ if (restored) return;
55
+ restored = true;
56
+ // Only reclaim the slot if it is still OUR replacement — never clobber a newer patcher.
57
+ if (console.error === replacement) console.error = original;
58
+ };
59
+
60
+ const resetQuietTimer = (): void => {
61
+ if (handle !== undefined) clearScheduled(handle);
62
+ handle = schedule(restore, opts.quietMs);
63
+ // Never keep the event loop alive on our account.
64
+ (handle as { unref?: () => void })?.unref?.();
65
+ };
66
+
67
+ const replacement = (...args: unknown[]): void => {
68
+ // Once restored, behave as the original — a stale reference still holding `replacement`
69
+ // (a module that cached console.error, or a wrapping patcher) never re-routes to the sink.
70
+ // And if the sink (or a downstream report()/ui.notify) ever writes to console.error while we
71
+ // are installed, delegate to the original instead of recursing into ourselves.
72
+ if (restored || active) {
73
+ original(...args);
74
+ return;
75
+ }
76
+ active = true;
77
+ try {
78
+ sink(args.map(String).join(" "));
79
+ } finally {
80
+ active = false;
81
+ }
82
+ resetQuietTimer();
83
+ };
84
+
85
+ console.error = replacement;
86
+ // Start the timer immediately so a zero-line case (no setup output) still restores after quietMs.
87
+ resetQuietTimer();
88
+
89
+ return { restore };
90
+ }
@@ -0,0 +1,34 @@
1
+ // A thin `git`-shelling seam for the extension interior — the TS twin of perk/substrate/git.py.
2
+ //
3
+ // Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
4
+ // never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
5
+ // where stated) rather than throwing — the carriers that use this must never wedge a session.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { isAbsolute, resolve } from "node:path";
9
+
10
+ /**
11
+ * The MAIN working tree's root, even when `cwd` is inside a linked worktree — the TS twin of
12
+ * `main_worktree_root`. Resolves `git rev-parse --git-common-dir` (the shared `.git` of the main
13
+ * checkout) and returns its parent (equal to the repo root in the main checkout). **Fail-open**:
14
+ * any failure (not a repo, git missing) returns `cwd`, so a session-pointer write always has a
15
+ * location — never throws. (Python returns `null` outside a repo; here the single caller wants
16
+ * `main_worktree_root(cwd) or cwd`, so we fold the fallback in.)
17
+ */
18
+ export function mainCheckoutRoot(cwd: string): string {
19
+ let out: string;
20
+ try {
21
+ out = execFileSync("git", ["rev-parse", "--git-common-dir"], {
22
+ cwd,
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ }).trim();
26
+ } catch {
27
+ return cwd;
28
+ }
29
+ if (out === "") return cwd;
30
+ // `--git-common-dir` may be relative (to `cwd`) or absolute; resolve then take the parent
31
+ // (the dir containing `.git` = the main checkout root).
32
+ const common = isAbsolute(out) ? out : resolve(cwd, out);
33
+ return resolve(common, "..");
34
+ }