@mgiles/perk 3.1.0 → 3.2.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 (43) hide show
  1. package/extension/doors/address.ts +11 -0
  2. package/extension/doors/dreamWaveTools.ts +29 -15
  3. package/extension/doors/land.ts +6 -0
  4. package/extension/doors/learn.ts +16 -3
  5. package/extension/doors/lifecycleGates.ts +36 -1
  6. package/extension/doors/objectiveStack.ts +423 -23
  7. package/extension/doors/plannotatorHandoff.ts +80 -8
  8. package/extension/doors/prReview.ts +2 -1
  9. package/extension/doors/prReviewBrowser.ts +75 -27
  10. package/extension/doors/ready.ts +209 -17
  11. package/extension/doors/reviewWaveTools.ts +24 -3
  12. package/extension/doors/stackReviewBrowser.ts +573 -0
  13. package/extension/doors/submit.ts +36 -10
  14. package/extension/doors/submitPrReview.ts +116 -19
  15. package/extension/factories/objectivePlan.ts +12 -6
  16. package/extension/factories/objectiveSave.ts +5 -2
  17. package/extension/index.ts +26 -1
  18. package/extension/substrate/config.ts +4 -2
  19. package/extension/substrate/paths.ts +2 -7
  20. package/extension/substrate/resolverLease.ts +363 -0
  21. package/extension/substrate/toolGating.ts +16 -0
  22. package/extension/substrate/workflowState.ts +13 -3
  23. package/extension/waves/adversarialReviewWave.ts +16 -2
  24. package/package.json +1 -1
  25. package/prompts/_fixtures/live.yaml +63 -0
  26. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  27. package/prompts/contexts/plan-authoring.md +6 -5
  28. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  29. package/prompts/stages/conflict-resolution.md +1 -1
  30. package/prompts/stages/objective-author/adopt.md +1 -1
  31. package/prompts/stages/objective-author/file.md +1 -1
  32. package/prompts/stages/objective-author/seed.md +1 -1
  33. package/prompts/stages/objective-reconcile-ready.md +7 -0
  34. package/prompts/stages/objective-sync.md +1 -1
  35. package/prompts/stages/stack-review/cold.md +1 -0
  36. package/prompts/stages/stack-review-browser/stack.md +23 -0
  37. package/shared/README.md +0 -3
  38. package/shared/bindings.yaml +3 -0
  39. package/shared/contracts.md +2010 -1753
  40. package/shared/registry.yaml +16 -1
  41. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  42. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  43. package/shared/contracts-history.md +0 -605
@@ -46,6 +46,7 @@ import {
46
46
  runReviewClassifierWave,
47
47
  } from "../waves/reviewClassifierWave.ts";
48
48
  import { createRpcWaveAdapter } from "../waves/rpcAdapter.ts";
49
+ import { planningStageRefusal } from "./lifecycleGates.ts";
49
50
  import { driveConflictResolution, type SubmitOk, submitPr } from "./submit.ts";
50
51
 
51
52
  export interface ThreadInput {
@@ -284,6 +285,10 @@ export async function finalizeAddress(
284
285
  params: ResolveParams,
285
286
  ): Promise<FinalizeAddressResult> {
286
287
  const fail = failFor<FinalizeAddressFailExtras>(ctx, "address", "finalize_address");
288
+ // Planning sessions never legitimately publish review fixes — the first check (a positioned
289
+ // stacked planning session's cwd binding is the PREDECESSOR).
290
+ const planningRefusal = planningStageRefusal(ctx, "address");
291
+ if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
287
292
  if (params.threads.length === 0) {
288
293
  return fail("no threads to finalize (pass { threads: [{thread_id, comment?}] })", "bad_input");
289
294
  }
@@ -529,6 +534,12 @@ export function registerAddress(pi: ExtensionAPI): void {
529
534
  "Classify PR review feedback (isolated child) and resolve threads (submit → address). " +
530
535
  "Pass --preview to classify only (take no action).",
531
536
  handler: async (args, ctx) => {
537
+ // Planning sessions never legitimately run the review loop — the first check.
538
+ const planningRefusal = planningStageRefusal(ctx, "address");
539
+ if (planningRefusal !== null) {
540
+ report(ctx, "address", "warning", planningRefusal);
541
+ return;
542
+ }
532
543
  const preview = /(^|\s)--preview(\s|$)/.test(args ?? "");
533
544
  // `/address` needs an active plan-ref (the converged body carries the PR identity, and the
534
545
  // classifier child's `perk pr feedback` hard-errors `no_plan_ref` without one). Mirror the
@@ -131,12 +131,13 @@ function resultText(details: DreamWaveOk): string {
131
131
  * record) and returns whether the cleared state was VERIFIED (append + read-back) — a false
132
132
  * return stops the wave before any filesystem work or spawn, because proceeding over an
133
133
  * unverified invalidation could leave a prior bundle + prior digest pair recoverable; `set`
134
- * publishes the finalized bytes' digest and stays loud-but-non-fatal (a failed set leaves the
135
- * marker cleared by the entry clear, so recovery refuses fail-closed, never silent). The
136
- * registered execute wires the production `appendWorkflowState` pair; tests inject fakes. */
134
+ * publishes the finalized bytes' digest and returns the append+read-back verification a
135
+ * failed set makes the wave outcome honestly incomplete (the marker stays cleared by the
136
+ * entry clear, so recovery refuses fail-closed, never silent). The registered execute wires
137
+ * the production `appendWorkflowState` pair; tests inject fakes. */
137
138
  export interface DreamBundleMarkers {
138
139
  clear(): boolean;
139
- set(digest: string): void;
140
+ set(digest: string): boolean;
140
141
  }
141
142
 
142
143
  /**
@@ -175,7 +176,10 @@ export interface DreamBundleMarkers {
175
176
  * undraftable), returning ok with `complete: false` and the bracket recorded;
176
177
  * 8. bracket ok ⇒ the finalize-in-place rewrite of the same fixed name; a throw ⇒ the second
177
178
  * post-launch `io_error` fail arm (mirroring arm 5's extras); on success
178
- * `markers.set(digest)` with the sha256 of the finalized bytes.
179
+ * `markers.set(digest)` with the sha256 of the finalized bytes — a failed set (an
180
+ * unverified append) makes the OK aggregate `complete: false` with a named
181
+ * `digest-marker` failure entry (the wave ran; the outcome is honestly incomplete —
182
+ * never the `io_error` fail arm), and the marker stays cleared so recovery refuses.
179
183
  */
180
184
  export async function executeDreamWave(
181
185
  adapter: WaveAdapter,
@@ -324,6 +328,7 @@ export async function executeDreamWave(
324
328
  // honest coverage reporting.
325
329
  bracket = opts.bracket();
326
330
  }
331
+ let markerSet = false;
327
332
  if (bracket?.ok === true) {
328
333
  // Finalize in place — the SAME fixed name gains the reducers section (never a second
329
334
  // file), then the digest marker publishes the finalized bytes for the recovery consumer.
@@ -344,13 +349,24 @@ export async function executeDreamWave(
344
349
  attempts,
345
350
  });
346
351
  }
347
- // A failed marker append warns loudly via the seam's read-back check and leaves the
348
- // marker cleared — recovery refuses (fail-closed); re-running the wave repairs it.
349
- opts.markers.set(digestSessionData(finalized));
352
+ // A failed marker append (an unverified read-back) leaves the marker cleared by the
353
+ // entry clear — recovery refuses (fail-closed) and the aggregate reports the outcome
354
+ // as honestly incomplete; re-running the wave repairs it.
355
+ markerSet = opts.markers.set(digestSessionData(finalized));
350
356
  }
351
357
 
358
+ const failures = [...reducers.failures];
359
+ if (bracket?.ok === true && !markerSet) {
360
+ failures.push({
361
+ angle: "digest-marker",
362
+ reason: "run-failed",
363
+ detail:
364
+ "dream_bundle_digest marker append failed its read-back — the marker stays cleared, " +
365
+ "so recovery refuses this bundle; re-run perk learn dream",
366
+ });
367
+ }
352
368
  const details: DreamWaveOk = {
353
- complete: analysis.complete && reducers.complete && bracket?.ok === true,
369
+ complete: analysis.complete && reducers.complete && bracket?.ok === true && markerSet,
354
370
  analysis: analysisDetails,
355
371
  bracket,
356
372
  bundle: {
@@ -365,7 +381,7 @@ export async function executeDreamWave(
365
381
  skip_reason: null,
366
382
  complete: reducers.complete,
367
383
  reports: reducers.reports,
368
- failures: reducers.failures,
384
+ failures,
369
385
  },
370
386
  attempts,
371
387
  };
@@ -444,8 +460,8 @@ export function registerDreamWave(pi: ExtensionAPI): void {
444
460
  const reducerModel = subagentModel(ctx.cwd, "dream-reducer");
445
461
  // The production digest-marker pair: the ordinary strict-append session-entry channel.
446
462
  // The boolean is the seam's verified append+read-back result — the execute core refuses
447
- // the wave on an unverified CLEAR (fail-closed); a failed SET stays loud-but-non-fatal
448
- // (the entry clear already invalidated, so recovery refuses).
463
+ // the wave on an unverified CLEAR (fail-closed); a failed SET makes the aggregate
464
+ // honestly incomplete (the entry clear already invalidated, so recovery refuses).
449
465
  const marker = (digest: string): boolean =>
450
466
  appendWorkflowState(pi, ctx, {
451
467
  data: { dream_bundle_digest: digest },
@@ -462,9 +478,7 @@ export function registerDreamWave(pi: ExtensionAPI): void {
462
478
  bracket: () => revalidationBracket(ctx.cwd, decoded.manifest.commit_sha),
463
479
  markers: {
464
480
  clear: () => marker(""),
465
- set: (digest) => {
466
- marker(digest);
467
- },
481
+ set: (digest) => marker(digest),
468
482
  },
469
483
  ...(analystModel !== undefined ? { analystModel } : {}),
470
484
  ...(reducerModel !== undefined ? { reducerModel } : {}),
@@ -21,6 +21,7 @@ import {
21
21
  import { registerPerkCommand } from "../substrate/command.ts";
22
22
  import { failFor, ok, type Result } from "../substrate/result.ts";
23
23
  import { report } from "../surfaces/report.ts";
24
+ import { planningStageRefusal } from "./lifecycleGates.ts";
24
25
 
25
26
  // Learn-consume skip reasons that are ordinary, not failures: non-factory plans carry no
26
27
  // `consumed_learn` (`no_consumed_learn`), and a dry run reports `dry_run`. Anything else surfaces.
@@ -132,6 +133,11 @@ function decodeLand(payload: ColdJson): LandPayload | null {
132
133
  export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
133
134
  const fail = failFor(ctx, "land");
134
135
 
136
+ // Planning sessions never legitimately land — the first check, before any cold-door
137
+ // delegation (a positioned stacked planning session's cwd binding is the PREDECESSOR).
138
+ const planningRefusal = planningStageRefusal(ctx, "land");
139
+ if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
140
+
135
141
  const r = await runColdDoor<LandPayload>(pi, ctx, ["pr", "land", "--json"], {
136
142
  label: "perk pr land",
137
143
  decode: decodeLand,
@@ -13,8 +13,8 @@
13
13
  // it validates the angle selection in code (2–4 angles, `session-deviations` mandatory — the
14
14
  // §8.35 policy as tested implementation), derives the manifest path from the relayed
15
15
  // `bundle_dir`, resolves the analyst model from `[models.subagents] learn-analyst` (because
16
- // `subagents.agentOverrides` does NOT reach project agents, the model rides the wave as the
17
- // workflow-level `model` default), and runs 2–4 fresh-context `perk.learn-analyst` lanes through
16
+ // an `agentOverrides` model can never displace the def's frontmatter-pinned `model:`, the model
17
+ // rides the wave as the workflow-level `model` default), and runs 2–4 fresh-context `perk.learn-analyst` lanes through
18
18
  // `runLearnWave` (best-effort completeness: a failed analyst is an explicitly-reported skipped
19
19
  // angle). A wave-level failure soft-fails LOUDLY — never a silent fallback to model-authored
20
20
  // scripts; the guidance routes the parent to a single-context analysis of the bundle instead.
@@ -71,7 +71,7 @@ import {
71
71
  type WaveAttemptReceipt,
72
72
  } from "../waves/reportWave.ts";
73
73
  import { createRpcWaveAdapter } from "../waves/rpcAdapter.ts";
74
- import { planReadInstruction } from "./lifecycleGates.ts";
74
+ import { planningStageRefusal, planReadInstruction } from "./lifecycleGates.ts";
75
75
 
76
76
  /** The ok-arm fields. */
77
77
  export interface LearnOk {
@@ -190,6 +190,12 @@ export async function learnDone(
190
190
  const trimmed = (summary ?? "").trim();
191
191
  const fail = failFor(ctx, "learn");
192
192
 
193
+ // Planning sessions never legitimately run the learn cycle — the first check, before any
194
+ // cold-door delegation (a positioned stacked planning session's cwd binding is the
195
+ // PREDECESSOR).
196
+ const planningRefusal = planningStageRefusal(ctx, "learn");
197
+ if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
198
+
193
199
  // No summary: record the deliberate skip canonically (the cold door stamps the plan-header and
194
200
  // clears the marker; the skip carries no classification, so `decision`/`target` are
195
201
  // intentionally ignored on this arm). On failure the marker is NOT cleared — never silently
@@ -567,6 +573,13 @@ export function registerLearn(pi: ExtensionAPI): void {
567
573
  "/learn skip records the skip on the plan and clears pending-learn; " +
568
574
  "/learn <text> captures the text verbatim.",
569
575
  handler: async (args, ctx) => {
576
+ // Planning sessions never legitimately run the learn cycle — the first check (the
577
+ // orchestrating bare-/learn arm below never reaches learnDone, so it needs its own gate).
578
+ const planningRefusal = planningStageRefusal(ctx, "learn");
579
+ if (planningRefusal !== null) {
580
+ report(ctx, "learn", "warning", planningRefusal);
581
+ return;
582
+ }
570
583
  const trimmed = (args ?? "").trim();
571
584
 
572
585
  // Explicit text (or `skip`): the existing learnDone path — capture verbatim / record skip.
@@ -16,9 +16,44 @@ import type {
16
16
  import type { PlanRef } from "../substrate/cache.ts";
17
17
  import { registerPerkCommand } from "../substrate/command.ts";
18
18
  import { render } from "../substrate/prompts.ts";
19
- import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
19
+ import { type BranchSource, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
20
20
  import { report } from "../surfaces/report.ts";
21
21
 
22
+ // The planning stages whose sessions never legitimately run lifecycle doors. After an approved
23
+ // save, a still-live planning session holds TWO plan identities — the cwd binding (a positioned
24
+ // stacked session's predecessor checkout, read via readPlanRef(ctx.cwd)) and the just-saved plan
25
+ // on active_plan_ref — so a door invocation there could act on the predecessor. At the repo root
26
+ // the same invocation fails confusingly today; the refusal is honest in both shapes.
27
+ const PLANNING_STAGES = new Set(["plan", "objective-plan"]);
28
+
29
+ /**
30
+ * The planning-stage lifecycle-door refusal (the shared first check of the warm /submit,
31
+ * /address, /land, and /learn doors): when this session's workflow-state `stage` is a planning
32
+ * stage, return the refusal message directing the human at the fresh-session implement door;
33
+ * `null` otherwise (non-planning stages — and stage-less sessions — are unaffected). Fail-CLOSED
34
+ * on an unreadable branch: without the state this guard cannot prove the session is not a
35
+ * positioned planning session (whose cwd binding is the PREDECESSOR — the exact target it
36
+ * protects), so an unreadable read refuses rather than letting the door act.
37
+ */
38
+ export function planningStageRefusal(ctx: BranchSource, door: string): string | null {
39
+ let stage: string | undefined;
40
+ try {
41
+ stage = rebuildWorkflowState(branchOf(ctx)).stage;
42
+ } catch (error) {
43
+ return (
44
+ `${door} is unavailable: the session's workflow state could not be read ` +
45
+ `(${String(error)}), so this cannot be proven not to be a planning session — ` +
46
+ "retry, or implement the saved plan with `perk impl <N>` in a fresh session."
47
+ );
48
+ }
49
+ if (stage === undefined || !PLANNING_STAGES.has(stage)) return null;
50
+ return (
51
+ `${door} is unavailable in a planning session (stage ${stage}): a planning session can ` +
52
+ "hold two plan identities (its checkout's own binding and the just-saved plan) — " +
53
+ "implement the saved plan with `perk impl <N>` in a fresh session instead."
54
+ );
55
+ }
56
+
22
57
  const DIRTY_MESSAGE = "uncommitted changes — commit or stash before switching/forking this stage.";
23
58
  const HANDOFF_DIRTY_MESSAGE =
24
59
  "uncommitted changes — commit before a fresh-context /implement handoff (the plan is the " +