@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.
- package/README.md +7 -21
- package/extension/checkpoints/checkpoints.ts +2 -1
- package/extension/doors/address.ts +2 -1
- package/extension/doors/ciExecutor.ts +3 -2
- package/extension/doors/land.ts +2 -1
- package/extension/doors/learn.ts +239 -33
- package/extension/doors/learnCode.ts +100 -0
- package/extension/doors/learnDocs.ts +4 -3
- package/extension/doors/lifecycleGates.ts +2 -1
- package/extension/doors/prReview.ts +20 -35
- package/extension/doors/prReviewLocal.ts +229 -0
- package/extension/doors/ready.ts +2 -1
- package/extension/doors/selfcheck.ts +2 -1
- package/extension/doors/submit.ts +17 -19
- package/extension/factories/implementHere.ts +116 -0
- package/extension/factories/objective.ts +2 -1
- package/extension/factories/objectivePlan.ts +4 -24
- package/extension/factories/objectiveSave.ts +5 -15
- package/extension/factories/planMode.ts +5 -1
- package/extension/factories/planReview.ts +103 -11
- package/extension/factories/planSave.ts +16 -1
- package/extension/index.ts +42 -3
- package/extension/substrate/bindings.ts +1 -1
- package/extension/substrate/cache.ts +2 -2
- package/extension/substrate/command.ts +25 -0
- package/extension/substrate/config.ts +12 -13
- package/extension/substrate/consoleCapture.ts +90 -0
- package/extension/substrate/git.ts +34 -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 +1 -1
- package/extension/substrate/sessionData.ts +1 -1
- package/extension/substrate/sessionPointers.ts +155 -0
- package/extension/substrate/toolGating.ts +8 -0
- package/extension/surfaces/surfaces.ts +10 -3
- package/extension/worker/worker.ts +111 -25
- package/extension/workerMain.ts +5 -3
- package/package.json +1 -5
- 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 +281 -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/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/seed.md +2 -1
- 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.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 +7 -1
- package/shared/bindings.yaml +12 -0
- package/shared/contracts-history.md +167 -0
- package/shared/contracts.md +1307 -342
- package/shared/registry.yaml +3 -3
- 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/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-context.schema.json +86 -0
- package/shared/schemas/outputs/pr-submit.schema.json +147 -0
- 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
|
@@ -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
|
|
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
|
|
10
|
-
// `ctx.ui.select` verdict
|
|
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; `
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
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
|
|
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).",
|
package/extension/index.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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`
|
|
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 `.
|
|
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
|
-
// `.
|
|
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, ".
|
|
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
|
-
// `.
|
|
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`
|
|
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 `.
|
|
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
|
|
218
|
-
merged = overlay(merged, readTomlFile(
|
|
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 `.
|
|
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
|
|
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(
|
|
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
|
+
}
|