@mgiles/perk 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -44
- package/extension/adapters/planAdapterPlannotator.ts +27 -41
- package/extension/adapters/planAdapterTombell.ts +15 -28
- package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
- package/extension/checkpoints/checkpoints.ts +19 -12
- package/extension/doors/address.ts +4 -4
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +21 -14
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +31 -9
- package/extension/doors/learn.ts +2 -2
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +4 -4
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/selfcheck.ts +238 -5
- package/extension/doors/submit.ts +20 -0
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/objective.ts +15 -5
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +12 -10
- package/extension/factories/objectiveSave.ts +2 -2
- package/extension/factories/planMode.ts +22 -40
- package/extension/factories/planReview.ts +213 -191
- package/extension/factories/planSave.ts +7 -7
- package/extension/index.ts +83 -25
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +34 -7
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/config.ts +88 -65
- package/extension/substrate/git.ts +43 -0
- package/extension/substrate/paths.ts +1 -1
- package/extension/substrate/prompts.ts +2 -2
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionPointers.ts +35 -6
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +330 -79
- package/extension/substrate/toolParams.ts +7 -0
- package/extension/substrate/workflowState.ts +54 -2
- package/extension/surfaces/footerProvider.ts +8 -4
- package/extension/surfaces/surfaces.ts +330 -12
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +77 -7
- package/extension/workerMain.ts +12 -13
- package/package.json +3 -3
- package/prompts/_fixtures/live.yaml +117 -2
- package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -0
- package/prompts/contexts/adapters/plannotator-plan.md +6 -0
- package/prompts/contexts/adapters/tombell-plan.md +17 -0
- package/prompts/contexts/objective-authoring.md +20 -0
- package/prompts/contexts/plan-authoring.md +24 -0
- package/prompts/contexts/read-only.md +10 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-replan.md +1 -1
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/pr-review-browser/active.md +11 -0
- package/prompts/stages/pr-review-browser/foreign.md +11 -0
- package/prompts/stages/pr-review-terminal/active.md +12 -0
- package/prompts/stages/pr-review-terminal/foreign.md +13 -0
- package/prompts/stages/pr-review-terminal/local.md +4 -0
- package/prompts/stages/pr-review.md +1 -1
- package/prompts/stages/replan.md +2 -2
- package/prompts/stages/skills/create-from.md +1 -1
- package/prompts/stages/skills/create.md +1 -1
- package/prompts/stages/skills/refine.md +1 -1
- package/shared/README.md +22 -18
- package/shared/bindings.yaml +10 -2
- package/shared/contracts-history.md +24 -0
- package/shared/contracts.md +1442 -1787
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +7 -8
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
- package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/extension/doors/learnCode.ts +0 -100
- package/extension/doors/learnDocs.ts +0 -100
- package/extension/doors/prReviewLocal.ts +0 -229
package/extension/index.ts
CHANGED
|
@@ -16,14 +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 {
|
|
20
|
-
import { registerLearnDocs } from "./doors/learnDocs.ts";
|
|
19
|
+
import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
|
|
21
20
|
import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
|
|
22
21
|
import { registerPrReview } from "./doors/prReview.ts";
|
|
23
|
-
import {
|
|
22
|
+
import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
|
|
23
|
+
import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
|
|
24
24
|
import { registerReady } from "./doors/ready.ts";
|
|
25
25
|
import { registerSelfcheck } from "./doors/selfcheck.ts";
|
|
26
26
|
import { registerSubmit } from "./doors/submit.ts";
|
|
27
|
+
import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
|
|
27
28
|
import { registerImplementHere } from "./factories/implementHere.ts";
|
|
28
29
|
import { registerObjective } from "./factories/objective.ts";
|
|
29
30
|
import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
|
|
@@ -60,7 +61,13 @@ import {
|
|
|
60
61
|
} from "./substrate/workflowState.ts";
|
|
61
62
|
import { isPerkFooterReferenceSelected } from "./surfaces/footerProvider.ts";
|
|
62
63
|
import { report } from "./surfaces/report.ts";
|
|
63
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
createPerkStatus,
|
|
66
|
+
installPerkFooter,
|
|
67
|
+
latestCacheHitRate,
|
|
68
|
+
registerTranscriptRenderer,
|
|
69
|
+
workflowStateEntryRenderer,
|
|
70
|
+
} from "./surfaces/surfaces.ts";
|
|
64
71
|
import { registerBtw } from "./vendor/btw/btw.ts";
|
|
65
72
|
import { registerWhimsical } from "./vendor/whimsical/whimsical.ts";
|
|
66
73
|
|
|
@@ -173,6 +180,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
173
180
|
// session_start (reload) could leak the previous handle subscription.
|
|
174
181
|
let footerInstalled = false;
|
|
175
182
|
|
|
183
|
+
// Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
|
|
184
|
+
// surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
|
|
185
|
+
// (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
|
|
186
|
+
registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
|
|
187
|
+
|
|
176
188
|
pi.on("session_start", async (_event, ctx) => {
|
|
177
189
|
const branchEntries = () => branchOf(ctx);
|
|
178
190
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
@@ -190,7 +202,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
202
|
cwd: ctx.cwd,
|
|
191
203
|
});
|
|
192
204
|
|
|
193
|
-
|
|
205
|
+
// `claim`/`adopt` carry no prior branch state (adopt's is written by its arm below).
|
|
206
|
+
let resolved: WorkflowState =
|
|
207
|
+
decision.action === "claim" || decision.action === "adopt" ? {} : decision.state;
|
|
194
208
|
let minted = false;
|
|
195
209
|
|
|
196
210
|
if (decision.action === "claim") {
|
|
@@ -234,6 +248,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
248
|
};
|
|
235
249
|
pi.appendEntry(WORKFLOW_STATE_TYPE, data);
|
|
236
250
|
resolved = data;
|
|
251
|
+
} else if (decision.action === "adopt") {
|
|
252
|
+
// An env-inherited run id whose handoff was already consumed by a different session: a
|
|
253
|
+
// spawned child (contracts §8.2). Mirror the fork arm — derived child identity, isolated
|
|
254
|
+
// scratch, inherited mode (read-only gating survives) — minus everything that belongs to
|
|
255
|
+
// the launched session: never re-consume the handoff (its pi_session_id keeps the true
|
|
256
|
+
// claimer), no `stage` (no stage impersonation / stage-binding injection), and no
|
|
257
|
+
// implementation/main pointer capture (resolveRunStage stays null for adopt).
|
|
258
|
+
ensureRunScratch(ctx.cwd, decision.childRunId);
|
|
259
|
+
const data: WorkflowState = {
|
|
260
|
+
run_id: decision.childRunId,
|
|
261
|
+
pi_session_id: currentSessionId ?? undefined,
|
|
262
|
+
predecessor: decision.parentRunId,
|
|
263
|
+
mode: decision.mode,
|
|
264
|
+
};
|
|
265
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, data);
|
|
266
|
+
resolved = data;
|
|
237
267
|
} else if (decision.action === "none") {
|
|
238
268
|
// A warm session with no identity mints its own run_id so
|
|
239
269
|
// per-run state (the session data dir) can key off it. No disk artifacts —
|
|
@@ -254,7 +284,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
254
284
|
}
|
|
255
285
|
}
|
|
256
286
|
|
|
257
|
-
//
|
|
287
|
+
// Reapply the read-only allowlist + stage scoping from the resolved mode/stage — FIRST,
|
|
288
|
+
// before the plan-ref/stage reconciliation below. `resolved.mode` is final once the
|
|
289
|
+
// claim/fork/none arms settle (the later blocks only touch `active_plan_ref` / capture
|
|
290
|
+
// pointers), and ordering the sync ahead of them guarantees no cache read or reconciliation
|
|
291
|
+
// failure can leave the gate unsynced (defense in depth on top of the total cache readers).
|
|
292
|
+
// The scope stage is the workflow-state `stage` key (§8.40): claim → the handoff-recorded
|
|
293
|
+
// stage just appended; keep/none → the branch-LWW stage; fork INHERITS the parent's stage (a
|
|
294
|
+
// forked implement session is an implement session); adopt NEVER impersonates (subagent
|
|
295
|
+
// children stay unscoped — their fresh branch carries no stage, so session_tree agrees). A
|
|
296
|
+
// failed claim leaves `resolved` empty → no stage → unscoped (stage scoping is fail-open).
|
|
297
|
+
// Fail-closed on the gate: if the sync throws, leave it as-is (a failed sync never opens it).
|
|
298
|
+
const scopeStage =
|
|
299
|
+
decision.action === "adopt"
|
|
300
|
+
? undefined
|
|
301
|
+
: (resolved.stage ?? (decision.action === "fork" ? decision.state.stage : undefined));
|
|
302
|
+
try {
|
|
303
|
+
gating.syncFromState(resolved.mode, scopeStage);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error(`perk: tool-gating sync failed on session_start — ${error}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Plan-ref linkage (stage-gated): reconcile the cache.plan-ref file into
|
|
258
309
|
// active_plan_ref — but ONLY when the launched stage *consumes* the ref (its registry
|
|
259
310
|
// `requires`/`reads` list `cache.plan-ref`). That is the worktree binding stages
|
|
260
311
|
// (implement/submit/address/land/learn); the root `worktree: none` stages
|
|
@@ -300,6 +351,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
300
351
|
// The headless worker's inner session lands here too (.main); driveStage records the matching
|
|
301
352
|
// .worker. A forked implement session inherits the parent's launched stage + threads the
|
|
302
353
|
// inherited parent session id as fork provenance. Best-effort + non-fatal (carrier warns).
|
|
354
|
+
// First-write-wins (`preserveForeign`): this is the corroborated shadowing defect site — the
|
|
355
|
+
// claimer's original capture stays authoritative, and any future shadow vector warns loudly
|
|
356
|
+
// instead of silently corrupting /learn evidence.
|
|
303
357
|
const implStage =
|
|
304
358
|
runStage ?? (decision.action === "fork" ? (decision.state.stage ?? null) : null);
|
|
305
359
|
if (resolved.run_id && implStage === "implement") {
|
|
@@ -310,17 +364,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
310
364
|
site: "main",
|
|
311
365
|
sessionFile,
|
|
312
366
|
parentSessionId: decision.action === "fork" ? (decision.state.pi_session_id ?? null) : null,
|
|
367
|
+
preserveForeign: true,
|
|
313
368
|
});
|
|
314
369
|
}
|
|
315
370
|
|
|
316
|
-
// Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
|
|
317
|
-
// leave the gate as-is (a failed sync never opens it).
|
|
318
|
-
try {
|
|
319
|
-
gating.syncFromState(resolved.mode);
|
|
320
|
-
} catch (error) {
|
|
321
|
-
console.error(`perk: tool-gating sync failed on session_start — ${error}`);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
371
|
// Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
|
|
325
372
|
// extension actually running may differ from the `perk` CLI that launched it. The local launch
|
|
326
373
|
// seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
|
|
@@ -351,6 +398,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
351
398
|
status: perkStatus,
|
|
352
399
|
getModelId: () => ctx.model?.id ?? null,
|
|
353
400
|
getThinkingLevel: () => (ctx.model ? pi.getThinkingLevel() : null),
|
|
401
|
+
getCacheHitRate: () => latestCacheHitRate(ctx.sessionManager.getEntries()),
|
|
354
402
|
getContext: () => {
|
|
355
403
|
const usage = ctx.getContextUsage();
|
|
356
404
|
return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
|
|
@@ -380,9 +428,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
380
428
|
// Non-negotiable: rebuild on branch navigation too, or state goes stale after /tree (§8.3).
|
|
381
429
|
pi.on("session_tree", async (_event, ctx) => {
|
|
382
430
|
const state = rebuildWorkflowState(branchOf(ctx));
|
|
383
|
-
// Non-negotiable: re-sync the gate on tree navigation too (mode
|
|
431
|
+
// Non-negotiable: re-sync the gate + stage scoping on tree navigation too (mode and stage are
|
|
432
|
+
// per-field LWW — the branch-rebuilt stage is the §8.40 key). Fail-closed on the gate.
|
|
384
433
|
try {
|
|
385
|
-
gating.syncFromState(state.mode);
|
|
434
|
+
gating.syncFromState(state.mode, state.stage);
|
|
386
435
|
} catch (error) {
|
|
387
436
|
console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
|
|
388
437
|
}
|
|
@@ -391,7 +440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
391
440
|
}
|
|
392
441
|
});
|
|
393
442
|
|
|
394
|
-
// Warm door: the `plan_save` tool + `/plan-save` command
|
|
443
|
+
// Warm door: the `plan_save` tool + `/plan-save` command. Takes `gating`:
|
|
395
444
|
// a successful command-path save exits read-only mode (the read-only → read-write boundary).
|
|
396
445
|
registerPlanSave(pi, gating);
|
|
397
446
|
|
|
@@ -413,16 +462,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
413
462
|
// before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
|
|
414
463
|
registerAskUser(pi);
|
|
415
464
|
|
|
416
|
-
// Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement
|
|
465
|
+
// Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement`.
|
|
417
466
|
registerLifecycleGates(pi);
|
|
418
467
|
|
|
419
|
-
// Warm door: the `submit` tool + `/submit` command
|
|
468
|
+
// Warm door: the `submit` tool + `/submit` command.
|
|
420
469
|
registerSubmit(pi);
|
|
421
470
|
|
|
422
471
|
// The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
|
|
423
472
|
registerReady(pi);
|
|
424
473
|
|
|
425
|
-
// Warm doors: `land`
|
|
474
|
+
// Warm doors: `land` merges + sets pending-learn; `learn` clears it (TS-only).
|
|
426
475
|
registerLand(pi);
|
|
427
476
|
registerLearn(pi);
|
|
428
477
|
|
|
@@ -435,9 +484,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
435
484
|
// POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
|
|
436
485
|
registerPrReview(pi);
|
|
437
486
|
|
|
438
|
-
// The warm
|
|
439
|
-
//
|
|
440
|
-
|
|
487
|
+
// The warm `submit_pr_review` tool: the human-gated curated-posting surface both review
|
|
488
|
+
// doors ride (contracts §8.4) — neither door registers tools of its own.
|
|
489
|
+
registerSubmitPrReview(pi);
|
|
490
|
+
|
|
491
|
+
// The warm `/pr-review-terminal` door: the terminal review entry — hunk always, no provider
|
|
492
|
+
// dispatch (the command IS the selection); posting rides `submit_pr_review` above.
|
|
493
|
+
registerPrReviewTerminal(pi);
|
|
494
|
+
|
|
495
|
+
// The warm `/pr-review-browser` door: the browser review entry — plannotator always, opened
|
|
496
|
+
// in the background (pre-PR it absorbs the since-base local browser review); posting is the
|
|
497
|
+
// human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
|
|
498
|
+
registerPrReviewBrowser(pi);
|
|
441
499
|
|
|
442
500
|
// The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
|
|
443
501
|
// flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
|
|
@@ -478,12 +536,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
478
536
|
// The learned-docs plan factory's warm surface: the `/learn-docs` command gathers open
|
|
479
537
|
// perk:learn issues into an inbox (via the `perk learn docs --gather` cold door) and injects the
|
|
480
538
|
// factory guidance so the model authors a docs/learned consolidation plan (no model tool).
|
|
481
|
-
|
|
539
|
+
registerLearnFactoryDoor(pi, DOCS_DOOR);
|
|
482
540
|
|
|
483
541
|
// The learn-code plan factory's warm surface: the `/learn-code` command gathers pre-stamped
|
|
484
542
|
// SHOULD_BE_CODE perk:learn issues into an inbox (via the `perk learn code --gather` cold door)
|
|
485
543
|
// and injects the factory guidance so the model authors a code-routing plan (no model tool).
|
|
486
|
-
|
|
544
|
+
registerLearnFactoryDoor(pi, CODE_DOOR);
|
|
487
545
|
|
|
488
546
|
// Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
|
|
489
547
|
// the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
//
|
|
15
15
|
// This is the SINGLE delivery path for perk's own nudges. Delivery NEVER double-delivers: the
|
|
16
16
|
// cold↔warm dedup marker is `BINDING_HEADER` itself — the cold door's initial prompt and every warm
|
|
17
|
-
// injection carry it, so Mechanism A injects ONLY when
|
|
18
|
-
// header (idempotent across turns/reloads; after compaction
|
|
17
|
+
// injection carry it, so Mechanism A injects ONLY when neither the branch NOR the submitting
|
|
18
|
+
// turn's prompt already carries the header (idempotent across turns/reloads; after compaction
|
|
19
|
+
// drops the original it re-delivers). The prompt scan is load-bearing on the launch turn: at
|
|
20
|
+
// `before_agent_start` the just-submitted prompt is NOT yet on the branch, so the branch scan
|
|
21
|
+
// alone would miss a cold seed's binding suffix and double-deliver.
|
|
19
22
|
//
|
|
20
23
|
// LBYL throughout: a missing/unreadable transclude target degrades to the nudge pointer with a
|
|
21
24
|
// loud-but-non-fatal warning, never throws, never blocks a turn. Resolver shape `issues` are NOT
|
|
@@ -26,7 +29,12 @@ import { join } from "node:path";
|
|
|
26
29
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
30
|
import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
|
|
28
31
|
import { loadPerkConfig } from "./config.ts";
|
|
29
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
type BranchEntry,
|
|
34
|
+
branchCarries,
|
|
35
|
+
branchOf,
|
|
36
|
+
rebuildWorkflowState,
|
|
37
|
+
} from "./workflowState.ts";
|
|
30
38
|
|
|
31
39
|
/**
|
|
32
40
|
* The cross-plane dedup marker AND render header. MUST stay byte-identical to the Python cold
|
|
@@ -58,7 +66,10 @@ export function resolvedBindings(cwd: string): SkillBinding[] {
|
|
|
58
66
|
|
|
59
67
|
/**
|
|
60
68
|
* Render the resolved bindings matching `trigger` into a header-joined fragment (or `null` when
|
|
61
|
-
* none match). `nudge` renders a `Follow the \`<skill>\` skill
|
|
69
|
+
* none match). `nudge` renders a `Follow the \`<skill>\` skill (read
|
|
70
|
+
* \`.agents/skills/<skill>/SKILL.md\`).` pointer — the read path is unconditional, so a skill
|
|
71
|
+
* hidden from the ambient prompt (`disable-model-invocation: true`) stays reachable; `transclude`
|
|
72
|
+
* inlines
|
|
62
73
|
* `.agents/skills/<skill>/SKILL.md` (frontmatter stripped), degrading to the nudge pointer with a
|
|
63
74
|
* loud-but-non-fatal warning when the file is absent/unreadable. Pure but for the LBYL file read.
|
|
64
75
|
*/
|
|
@@ -86,7 +97,10 @@ export function renderBindings(cwd: string, trigger: string): BindingRender {
|
|
|
86
97
|
`under ${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME} — the pointer may dangle.`,
|
|
87
98
|
);
|
|
88
99
|
}
|
|
89
|
-
parts.push(
|
|
100
|
+
parts.push(
|
|
101
|
+
`Follow the \`${binding.skill}\` skill ` +
|
|
102
|
+
`(read \`${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME}\`).`,
|
|
103
|
+
);
|
|
90
104
|
}
|
|
91
105
|
const text = parts.length > 0 ? [BINDING_HEADER, ...parts].join("\n\n") : null;
|
|
92
106
|
return { text, warnings };
|
|
@@ -116,7 +130,11 @@ function readSkillBody(cwd: string, skill: string): string | null {
|
|
|
116
130
|
const path = join(cwd, SKILLS_SUBDIR, skill, SKILL_FILENAME);
|
|
117
131
|
if (!existsSync(path)) return null;
|
|
118
132
|
try {
|
|
119
|
-
|
|
133
|
+
// Normalize CRLF/CR before stripping (the miniJinja.ts pattern): Node's readFileSync keeps
|
|
134
|
+
// `\r\n` where Python's read_text() normalizes, so without this a CRLF checkout would defeat
|
|
135
|
+
// the `---\n` frontmatter check AND break the cross-plane byte parity pinned by
|
|
136
|
+
// tests/test_binding_render_parity.py.
|
|
137
|
+
return stripFrontmatter(readFileSync(path, "utf8").replace(/\r\n?/g, "\n"));
|
|
120
138
|
} catch {
|
|
121
139
|
return null;
|
|
122
140
|
}
|
|
@@ -142,7 +160,7 @@ function stripFrontmatter(text: string): string {
|
|
|
142
160
|
* header is a distinctive literal, so a substring hit means "already delivered on this branch".
|
|
143
161
|
*/
|
|
144
162
|
function branchHasHeader(branch: readonly BranchEntry[]): boolean {
|
|
145
|
-
return branch
|
|
163
|
+
return branchCarries(branch, BINDING_HEADER);
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
/** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
|
|
@@ -160,13 +178,17 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
|
|
|
160
178
|
*/
|
|
161
179
|
export function registerBindingDelivery(pi: ExtensionAPI): void {
|
|
162
180
|
// Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
|
|
163
|
-
// but ONLY when no entry on the branch
|
|
164
|
-
// prompt or a prior warm inject) — the cold↔warm
|
|
165
|
-
|
|
181
|
+
// but ONLY when no entry on the branch AND not the submitting turn's prompt already carries
|
|
182
|
+
// BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
|
|
183
|
+
// idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
|
|
184
|
+
// prompt is not yet on the branch; a worker prompt carries no header, so Mechanism A still
|
|
185
|
+
// fires there (contracts.md §8.38).
|
|
186
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
166
187
|
const branch = branchOf(ctx);
|
|
167
188
|
const rendered = activeStageRender(ctx.cwd, branch);
|
|
168
189
|
if (rendered === null || rendered.text === null) return;
|
|
169
190
|
if (branchHasHeader(branch)) return;
|
|
191
|
+
if (event.prompt.includes(BINDING_HEADER)) return;
|
|
170
192
|
for (const warning of rendered.warnings) console.error(`perk: ${warning}`);
|
|
171
193
|
return {
|
|
172
194
|
message: {
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
// Type-only import (config.ts value-imports this module; a value import here would cycle).
|
|
16
|
+
import type { TomlScalar } from "./config.ts";
|
|
15
17
|
import { parse } from "./miniYaml.ts";
|
|
16
18
|
import { sharedDir } from "./resources.ts";
|
|
17
19
|
|
|
@@ -74,10 +76,10 @@ export function loadDefaultBindings(): SkillBinding[] {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
|
-
* Parse `.perk/config.toml` `[[bindings]]` rows (
|
|
79
|
+
* Parse `.perk/config.toml` `[[bindings]]` rows (scalar tables) into `SkillBinding`s. Tolerant like
|
|
78
80
|
* the YAML reader: absent/ill-typed fields become empty strings so the *resolver* reports them.
|
|
79
81
|
*/
|
|
80
|
-
export function parseUserBindings(rows: Array<Record<string,
|
|
82
|
+
export function parseUserBindings(rows: Array<Record<string, TomlScalar>>): SkillBinding[] {
|
|
81
83
|
return rows.map((row) => {
|
|
82
84
|
const trigger = typeof row.trigger === "string" ? row.trigger : "";
|
|
83
85
|
const [kind, targetId] = splitTrigger(trigger);
|
|
@@ -3,6 +3,13 @@
|
|
|
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
|
|
5
5
|
// relative paths (only node builtins), so this module loads cleanly under `node --test`.
|
|
6
|
+
//
|
|
7
|
+
// Readers are TOTAL: a corrupt/unreadable file is reported loudly on stderr (`console.error` —
|
|
8
|
+
// the report() seam is intentionally unavailable here, and stderr is headless-safe) and treated
|
|
9
|
+
// as absent (`null`), so a bad cache blob can never crash a caller mid-`session_start` before
|
|
10
|
+
// the read-only gate engages. The Python twins (src/perk/state/cache.py) deliberately keep
|
|
11
|
+
// RAISING `CacheError` (exterior plane, launch-time fail-loud) — the cross-plane contract is
|
|
12
|
+
// the *files*, not error semantics.
|
|
6
13
|
|
|
7
14
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
15
|
import { join } from "node:path";
|
|
@@ -21,6 +28,25 @@ export function workflowDir(cwd: string): string {
|
|
|
21
28
|
return join(cwd, ".perk", "workflow");
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Read + parse a JSON cache blob, totally: a missing file is a silent `null` (absence is the
|
|
33
|
+
* normal state); an unreadable/corrupt/wrong-shape file is a LOUD `null` (one stderr line naming
|
|
34
|
+
* the file kind + path + error) — treated as absent by every caller.
|
|
35
|
+
*/
|
|
36
|
+
function readJsonOrNull<T>(path: string, what: string): T | null {
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
try {
|
|
39
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
41
|
+
throw new Error(`expected a JSON object, got ${JSON.stringify(parsed)}`);
|
|
42
|
+
}
|
|
43
|
+
return parsed as T;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error(`perk: unreadable ${what} at ${path} — treating as absent (${error})`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
// --- handoff -----------------------------------------------------------------------------
|
|
25
51
|
|
|
26
52
|
export function handoffPath(cwd: string, runId: string): string {
|
|
@@ -28,9 +54,7 @@ export function handoffPath(cwd: string, runId: string): string {
|
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
export function readHandoff(cwd: string, runId: string): Handoff | null {
|
|
31
|
-
|
|
32
|
-
if (!existsSync(path)) return null;
|
|
33
|
-
return JSON.parse(readFileSync(path, "utf8")) as Handoff;
|
|
57
|
+
return readJsonOrNull<Handoff>(handoffPath(cwd, runId), "handoff");
|
|
34
58
|
}
|
|
35
59
|
|
|
36
60
|
/** Mark a handoff consumed (idempotent); a no-op when absent. Keeps the file (audit + GC). */
|
|
@@ -113,9 +137,7 @@ export function planRefPath(cwd: string): string {
|
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
export function readPlanRef(cwd: string): PlanRef | null {
|
|
116
|
-
|
|
117
|
-
if (!existsSync(path)) return null;
|
|
118
|
-
return JSON.parse(readFileSync(path, "utf8")) as PlanRef;
|
|
140
|
+
return readJsonOrNull<PlanRef>(planRefPath(cwd), "plan-ref");
|
|
119
141
|
}
|
|
120
142
|
|
|
121
143
|
export function writePlanRef(cwd: string, ref: PlanRef): void {
|
|
@@ -137,7 +159,12 @@ export function planBodyPath(cwd: string): string {
|
|
|
137
159
|
export function readPlanBody(cwd: string): string | null {
|
|
138
160
|
const path = planBodyPath(cwd);
|
|
139
161
|
if (!existsSync(path)) return null;
|
|
140
|
-
|
|
162
|
+
try {
|
|
163
|
+
return readFileSync(path, "utf8");
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(`perk: unreadable plan body at ${path} — treating as absent (${error})`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
141
168
|
}
|
|
142
169
|
|
|
143
170
|
// --- markers (existence-only) ------------------------------------------------------------
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A fail-soft "copy this text to the OS clipboard" seam for the extension interior. Used by the
|
|
2
|
+
// terminal review door's R7 hunk handoff so the human always has the launch command one paste
|
|
3
|
+
// away — belt-and-braces beside the loud print and the terminal auto-launch.
|
|
4
|
+
//
|
|
5
|
+
// Two functions: a pure `resolveClipboardScript` (platform + env → a POSIX `sh` script, or `null`
|
|
6
|
+
// for "don't copy") and a thin `copyToClipboard` runner that stages the text in a temp file and
|
|
7
|
+
// shells the script. Never throws, never notifies — the caller owns all messaging. The
|
|
8
|
+
// `PERK_CLIPBOARD_CMD` env seam: unset → the platform default; empty → disabled (the test seam);
|
|
9
|
+
// non-empty → a custom copier.
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { ExecHost } from "./coldDoor.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the clipboard-copy shell script for `platform` + the `PERK_CLIPBOARD_CMD` override. The
|
|
18
|
+
* script copies the file named by `$1`; returns `null` for "don't copy".
|
|
19
|
+
*
|
|
20
|
+
* - `envCmd === ""` → `null` (the test seam / explicit opt-out);
|
|
21
|
+
* - `envCmd` non-empty → `<envCmd> < "$1"` (custom override — receives the file path as `$1`);
|
|
22
|
+
* - `darwin` → `pbcopy < "$1"`;
|
|
23
|
+
* - `linux`/other POSIX → a `command -v` chain (`wl-copy` → `xclip` → `xsel`), else `exit 127`;
|
|
24
|
+
* - `win32` → `null` (no POSIX `sh`; the print+launch fallback covers it).
|
|
25
|
+
*/
|
|
26
|
+
export function resolveClipboardScript(
|
|
27
|
+
platform: string,
|
|
28
|
+
envCmd: string | undefined,
|
|
29
|
+
): string | null {
|
|
30
|
+
if (envCmd !== undefined) {
|
|
31
|
+
if (envCmd === "") return null;
|
|
32
|
+
return `${envCmd} < "$1"`;
|
|
33
|
+
}
|
|
34
|
+
if (platform === "darwin") return `pbcopy < "$1"`;
|
|
35
|
+
if (platform === "win32") return null;
|
|
36
|
+
return (
|
|
37
|
+
'if command -v wl-copy >/dev/null 2>&1; then wl-copy < "$1"; ' +
|
|
38
|
+
'elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard < "$1"; ' +
|
|
39
|
+
'elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input < "$1"; ' +
|
|
40
|
+
"else exit 127; fi"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Copy `text` to the OS clipboard, best-effort. Resolves the script from `process.platform` +
|
|
46
|
+
* `process.env.PERK_CLIPBOARD_CMD`; a `null` script (win32, or the disabled seam) short-circuits
|
|
47
|
+
* to `false` without shelling. Otherwise stages `text` in a temp file and runs
|
|
48
|
+
* `sh -c <script> sh <file>` with a 3s cap (guards a display-less `xclip` hanging). Returns
|
|
49
|
+
* `true` iff the copier exited 0 and was not killed. Any throw → `false`. Never rejects, never
|
|
50
|
+
* notifies.
|
|
51
|
+
*/
|
|
52
|
+
export async function copyToClipboard(
|
|
53
|
+
pi: ExecHost,
|
|
54
|
+
ctx: { cwd: string; signal?: AbortSignal },
|
|
55
|
+
text: string,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
const script = resolveClipboardScript(process.platform, process.env.PERK_CLIPBOARD_CMD);
|
|
58
|
+
if (script === null) return false;
|
|
59
|
+
let dir: string | undefined;
|
|
60
|
+
try {
|
|
61
|
+
dir = mkdtempSync(join(tmpdir(), "perk-clip-"));
|
|
62
|
+
const file = join(dir, "clip.txt");
|
|
63
|
+
writeFileSync(file, text, "utf8");
|
|
64
|
+
const res = await pi.exec("sh", ["-c", script, "sh", file], {
|
|
65
|
+
cwd: ctx.cwd,
|
|
66
|
+
signal: ctx.signal,
|
|
67
|
+
timeout: 3000,
|
|
68
|
+
});
|
|
69
|
+
return res.code === 0 && !res.killed;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
} finally {
|
|
73
|
+
if (dir !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
} catch {
|
|
77
|
+
// best-effort cleanup — a leaked temp file is harmless
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|