@mgiles/perk 2.3.0 → 3.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.
Files changed (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -34,8 +34,9 @@
34
34
  // discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
35
35
  // so the dialog titles carry the key hints — pi renders no other affordance.
36
36
  //
37
- // THE OBJECTIVE ARM: an objective-author session (read-only, stage
38
- // `objective-author`) routes through `executeObjectiveReview` instead of the plan path the
37
+ // THE OBJECTIVE ARM: an objective-authoring session (read-only, stage `objective-author` or
38
+ // `objective-save` the two stages whose working draft IS the objective draft; neither carries
39
+ // `plan_draft`) routes through `executeObjectiveReview` instead of the plan path — the
39
40
  // reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
40
41
  // objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
41
42
  // soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
@@ -49,7 +50,11 @@
49
50
  // `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
50
51
  // (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
51
52
  // never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
52
- // `reason: "no_gist_draft"`.
53
+ // `reason: "no_gist_draft"`. Plannotator approve-with-Direct-Edits mirrors the objective arm:
54
+ // the save seam re-reads the STRUCTURED gist artifact, so rendered edits cannot be folded back
55
+ // mechanically — the arm SKIPS the save and returns one model-mediated revise round (fold each
56
+ // hunk into the matching `gist_draft` field — title heading → `title`, `Scope:` line → `scope`,
57
+ // prose → `prose` — then re-review to confirm).
53
58
  //
54
59
  // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
55
60
  // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
@@ -75,7 +80,11 @@ import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
75
80
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
76
81
  import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
77
82
  import { readNodeClaim } from "./objectivePlan.ts";
78
- import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
83
+ import {
84
+ OBJECTIVE_SAVE_STAGE,
85
+ type ObjectiveApprovalSaveOutcome,
86
+ objectiveApprovalSave,
87
+ } from "./objectiveSave.ts";
79
88
  import { writePlanDraft } from "./planDraft.ts";
80
89
  import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
81
90
 
@@ -410,9 +419,62 @@ export function implementHereResult(
410
419
  };
411
420
  }
412
421
 
422
+ // ------------------------------------------------------ the plannotator Direct-Edits apply
423
+
424
+ /**
425
+ * The shared plannotator APPROVE mechanical-apply path (contracts.md §8.23): inspect an
426
+ * APPROVED outcome's feedback for a `# Direct Edits` section and mechanically apply the
427
+ * reviewer's diff to the exact bytes reviewed (`basePlan`), writing the patched bytes back to
428
+ * the draft (reviewed bytes == artifact bytes == saved bytes). Consumed by `executePlanReview`'s
429
+ * plannotator arm AND the `/plan-review-browser` door — one apply path, byte-identical
430
+ * semantics:
431
+ *
432
+ * - only an `approved` outcome WITH feedback is inspected (anything else passes through
433
+ * verbatim);
434
+ * - a clean extract + apply + write-back swaps `reviewedPlan` to the patched bytes, sets
435
+ * `edited: true`, and strips the applied section from the returned outcome's feedback (only
436
+ * the annotation remainder survives — the applied diff must never render as "apply these
437
+ * exact changes" guidance);
438
+ * - a seen-but-unhonorable heading (or a failed apply / write-back) sets
439
+ * `directEditsFailed: true` with the plan left verbatim (the caller renders the loud warning;
440
+ * the diff stays in the surfaced feedback for a manual follow-up).
441
+ */
442
+ export function applyPlannotatorDirectEdits(
443
+ pi: ExtensionAPI,
444
+ ctx: ExtensionContext,
445
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
446
+ basePlan: string,
447
+ ): {
448
+ outcome: Extract<ReviewOutcome, { status: "completed" }>;
449
+ reviewedPlan: string;
450
+ edited: boolean;
451
+ directEditsFailed: boolean;
452
+ } {
453
+ if (!outcome.approved || outcome.feedback === undefined) {
454
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
455
+ }
456
+ const section = extractDirectEdits(outcome.feedback);
457
+ if (section !== null) {
458
+ const patched = applyUnifiedDiff(basePlan, section.diff);
459
+ if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
460
+ return {
461
+ outcome: { ...outcome, feedback: section.remainder },
462
+ reviewedPlan: patched,
463
+ edited: true,
464
+ directEditsFailed: false,
465
+ };
466
+ }
467
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
468
+ }
469
+ if (hasDirectEditsHeading(outcome.feedback)) {
470
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
471
+ }
472
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
473
+ }
474
+
413
475
  // ----------------------------------------------------------------- the first-party review core
414
476
 
415
- /** The minimal structural `ctx.ui` subset the first-party review needs (the askUser.ts recipe). */
477
+ /** The minimal structural `ctx.ui` subset the first-party review needs (the ciExecutor.ts pure-core + injected-fakes recipe). */
416
478
  export interface PlanReviewUI {
417
479
  editor(title: string, prefill?: string): Promise<string | undefined>;
418
480
  select(
@@ -444,7 +506,7 @@ const REVIEW_EDITOR_TITLE =
444
506
  const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
445
507
 
446
508
  /**
447
- * The first-party in-TUI review core, pure over injected seams (the askUser.ts recipe) — fully
509
+ * The first-party in-TUI review core, pure over injected seams (the ciExecutor.ts pure-core + injected-fakes recipe) — fully
448
510
  * offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
449
511
  * may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
450
512
  * written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
@@ -721,7 +783,12 @@ export function approvedGistSaveResult(
721
783
  * run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
722
784
  * implement-here verdict is never offered (a gist is not implementable — it has no strategy).
723
785
  * An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
724
- * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
786
+ * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`. ONE
787
+ * carve-out (plannotator only, mirroring the objective arm): an approval whose feedback opens a
788
+ * Direct Edits section SKIPS the save — rendered edits cannot be folded back into the
789
+ * structured draft mechanically — and returns a NON-terminating revise round with the gate
790
+ * untouched (fold each hunk into the matching `gist_draft` field, re-review to confirm); perk
791
+ * never saves a gist the reviewer explicitly edited away from.
725
792
  */
726
793
  export async function executeGistReview(
727
794
  pi: ExtensionAPI,
@@ -762,6 +829,42 @@ export async function executeGistReview(
762
829
  let outcome: ReviewOutcome;
763
830
  if (isPlannotatorPlanSelected(ctx.cwd)) {
764
831
  outcome = await bridge.review(rendered, sig);
832
+ // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
833
+ // approved-save routing (the approved-first discipline; mirrors the objective arm): the
834
+ // save seam re-reads the STRUCTURED artifact, so rendered edits cannot be folded back
835
+ // without model judgment. Skip the save, keep the gate read-only, and route ONE revise
836
+ // round: the model folds each hunk into the matching `gist_draft` field, then re-reviews
837
+ // to confirm. The heading check suffices (extraction success is irrelevant here — the diff
838
+ // goes to the model verbatim either way).
839
+ if (
840
+ outcome.status === "completed" &&
841
+ outcome.approved &&
842
+ outcome.feedback !== undefined &&
843
+ hasDirectEditsHeading(outcome.feedback)
844
+ ) {
845
+ return {
846
+ content: [
847
+ {
848
+ type: "text",
849
+ text:
850
+ "gist APPROVED with direct browser edits — these cannot be auto-applied to the " +
851
+ "structured draft, so nothing was saved. Fold each Direct Edits hunk below into " +
852
+ "the matching gist_draft field (a `# <title>` heading hunk → title, a `Scope:` " +
853
+ "line hunk → scope, prose hunks → prose), then call plan_review again to " +
854
+ `confirm.\n\nReviewer feedback:\n${outcome.feedback}`,
855
+ },
856
+ ],
857
+ details: {
858
+ ok: true,
859
+ status: "revise",
860
+ reason: "direct_edits",
861
+ approved: true,
862
+ feedback: outcome.feedback,
863
+ reviewId: outcome.reviewId,
864
+ subject: "gist",
865
+ },
866
+ };
867
+ }
765
868
  } else {
766
869
  const fp = await runFirstPartyReview({
767
870
  ui: ctx.ui,
@@ -790,8 +893,9 @@ export async function executeGistReview(
790
893
  /**
791
894
  * The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
792
895
  * ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
793
- * param decode → the objective-author arm (`executeObjectiveReview` — the rendered objective
794
- * draft is the review subject there) → headless skip → file-first resolution → backend
896
+ * param decode → the objective arm (`executeObjectiveReview` — the rendered objective draft is
897
+ * the review subject in BOTH objective-authoring stages) → headless skip → file-first
898
+ * resolution → backend
795
899
  * dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
796
900
  * in-TUI editor review) → approved → `approvalSave`.
797
901
  */
@@ -825,11 +929,14 @@ export async function executePlanReview(
825
929
  },
826
930
  };
827
931
  }
828
- // 1. Objective-author session the objective review arm: the rendered
829
- // objective draft is the sole review source; a well-typed `plan` param is ignored here.
830
- // A gist-author session likewise routes to the gist arm (the rendered gist draft).
932
+ // 1. An objective-authoring session (objective-author OR objective-save both stages'
933
+ // working draft is the objective draft, and neither carries `plan_draft`) the objective
934
+ // review arm: the rendered objective draft is the sole review source; a well-typed `plan`
935
+ // param is ignored here — the plan-arm fallthrough could otherwise review/save an
936
+ // unrelated plan param from an objective session. A gist-author session likewise routes to
937
+ // the gist arm (the rendered gist draft).
831
938
  const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
832
- if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
939
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
833
940
  return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
834
941
  }
835
942
  if (launchedStage === GIST_AUTHOR_STAGE) {
@@ -869,29 +976,17 @@ export async function executePlanReview(
869
976
  if (isPlannotatorPlanSelected(ctx.cwd)) {
870
977
  outcome = await bridge.review(src.plan, sig);
871
978
  // APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
872
- // reviewer's diff to the exact bytes reviewed, write it back to the draft (reviewed bytes ==
873
- // artifact bytes == saved bytes the first-party pre-verdict write-back, replayed here
874
- // post-verdict because the bridge only reports the diff), and save the EDITED bytes. Every
875
- // rung fails open to the verbatim path: no section untouched; a heading that cannot be
876
- // parsed / applied / written back → verbatim save + a loud warning (never save bytes the
877
- // artifact doesn't carry). DENY stays model-mediated — the feedback (diff included) passes
878
- // through for the plan_draft rewrite.
879
- if (outcome.status === "completed" && outcome.approved && outcome.feedback !== undefined) {
880
- const section = extractDirectEdits(outcome.feedback);
881
- if (section !== null) {
882
- const patched = applyUnifiedDiff(src.plan, section.diff);
883
- if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
884
- reviewedPlan = patched;
885
- edited = true;
886
- // The applied diff must NOT survive into the result as "apply these exact changes"
887
- // guidance — only the annotation remainder (when any) stays reviewer feedback.
888
- outcome = { ...outcome, feedback: section.remainder };
889
- } else {
890
- directEditsFailed = true;
891
- }
892
- } else if (hasDirectEditsHeading(outcome.feedback)) {
893
- directEditsFailed = true;
894
- }
979
+ // reviewer's diff via the shared helper (the first-party pre-verdict write-back, replayed
980
+ // here post-verdict because the bridge only reports the diff), and save the EDITED bytes.
981
+ // Every rung fails open to the verbatim path (never save bytes the artifact doesn't carry).
982
+ // DENY stays model-mediated the feedback (diff included) passes through for the
983
+ // plan_draft rewrite.
984
+ if (outcome.status === "completed") {
985
+ const applied = applyPlannotatorDirectEdits(pi, ctx, outcome, src.plan);
986
+ outcome = applied.outcome;
987
+ reviewedPlan = applied.reviewedPlan;
988
+ edited = applied.edited;
989
+ directEditsFailed = applied.directEditsFailed;
895
990
  }
896
991
  } else {
897
992
  // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
@@ -92,16 +92,35 @@ export async function generatePlanTitle(
92
92
  ): Promise<string | null> {
93
93
  if (!llmTitlesEnabled()) return null;
94
94
 
95
- let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
96
- try {
97
- auth = await resolveModelAuth(ctx);
98
- } catch {
99
- return null;
95
+ const model = ctx.model;
96
+ if (!model) return null; // silent: no model configured (fail-safe).
97
+
98
+ // Primary path (pi ≥ 0.84): registry dispatch — pi owns final request assembly (resolved auth,
99
+ // nullable headers, credential-resolved baseUrl, provider env). Feature-detected: the method is
100
+ // absent on older hosts, where the widened resolveModelAuth fallback keeps the old shape.
101
+ const registry = ctx.modelRegistry;
102
+ const registryComplete = registry.complete;
103
+ let authOptions: Pick<
104
+ Parameters<typeof completeStructured>[0],
105
+ "dispatch" | "apiKey" | "headers" | "env"
106
+ >;
107
+ if (typeof registryComplete === "function") {
108
+ authOptions = {
109
+ dispatch: (m, context, options) => registryComplete.call(registry, m, context, options),
110
+ };
111
+ } else {
112
+ let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
113
+ try {
114
+ auth = await resolveModelAuth(ctx);
115
+ } catch {
116
+ return null;
117
+ }
118
+ if (!auth.ok) return null; // silent: no auth configured (fail-safe).
119
+ authOptions = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env };
100
120
  }
101
- if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
102
121
 
103
122
  const outcome = await completeStructured({
104
- model: auth.model,
123
+ model,
105
124
  schema: PlanTitleSchema,
106
125
  toolName: "set_plan_title",
107
126
  toolDescription: "Provide the chosen title and category for the plan.",
@@ -110,8 +129,7 @@ export async function generatePlanTitle(
110
129
  "Read this implementation plan and choose a title and category. The title must be a concise, " +
111
130
  "imperative phrase, at most ~70 characters, with no trailing period and no markdown.",
112
131
  input: planMarkdown.slice(0, TITLE_INPUT_CHAR_CAP),
113
- apiKey: auth.apiKey,
114
- headers: auth.headers,
132
+ ...authOptions,
115
133
  signal,
116
134
  });
117
135
  if (!outcome.ok || !outcome.value) {
@@ -0,0 +1,389 @@
1
+ // The hunk watch feedback inbox (contracts.md §8.58): the single-flight delivery machine that
2
+ // drains the worktree outbox into the one eligible implement session.
3
+ //
4
+ // idle → dispatching → awaiting-observation → acknowledged → idle
5
+ // └→ backoff → dispatching
6
+ //
7
+ // Exactly ONE unacknowledged batch exists between injection and acknowledgement; while it awaits
8
+ // observation no new batch is injected — triggers (watch events, poll ticks, overflow) only mark
9
+ // the inbox dirty. Acknowledgement requires transcript OBSERVATION (`transport.isInjected`), not
10
+ // call-return: Pi's `sendUserMessage` is a void wrapper over in-memory queues an abort discards.
11
+ // Backoff resets ONLY on observation, so persistent asynchronous rejection backs off
12
+ // exponentially instead of retrying every poll tick — and unconfirmed messages can never
13
+ // accumulate (one in flight, ever). Duplicates possible, silent loss not.
14
+ //
15
+ // Failure containment: every background callback is wrapped — nothing ever escapes into the
16
+ // host session. Watcher failure degrades permanently to poll-only (polling is the correctness
17
+ // path); a failed lease verification closes the inbox fail-closed (misdelivery is never the
18
+ // fallback). Diagnostics route through `deps.report`, never into the model conversation.
19
+ //
20
+ // All effects arrive through `deps` (clock, timers, watch factory, report sink) — deterministic
21
+ // under test, no hidden globals.
22
+
23
+ import {
24
+ hunkConsumerLockDir,
25
+ hunkDeliveredPath,
26
+ hunkOutboxPath,
27
+ hunkWatchDir,
28
+ } from "../substrate/cache.ts";
29
+ import { lsFiles } from "../substrate/git.ts";
30
+ import {
31
+ acquireLease,
32
+ appendAcks,
33
+ type DeliveryAck,
34
+ type FeedbackRecord,
35
+ HEARTBEAT_MS,
36
+ readDeliveredIds,
37
+ readOutbox,
38
+ releaseLease,
39
+ renewHeartbeat,
40
+ sweepQuarantine,
41
+ verifyLease,
42
+ } from "./store.ts";
43
+
44
+ // Implementation constants (§8.58) — code constants, deliberately not config.
45
+ export const DEBOUNCE_MS = 500;
46
+ export const POLL_MS = 10_000;
47
+ export const BATCH_MAX_RECORDS = 10;
48
+ export const BATCH_MAX_BYTES = 49_152;
49
+ export const BACKOFF_BASE_MS = 1_000;
50
+ export const BACKOFF_CAP_MS = 60_000;
51
+ /** An in-flight batch is only demotable once it has had ≥ one poll interval to land. */
52
+ export const IN_FLIGHT_MIN_AGE_MS = POLL_MS;
53
+
54
+ export interface ConsumerIdentity {
55
+ cwd: string;
56
+ runId: string;
57
+ piSessionId: string;
58
+ /** The worktree plan-ref's `pr_id` — records for any other plan are held, never delivered. */
59
+ planId: string;
60
+ }
61
+
62
+ export interface FeedbackTransport {
63
+ /** Render + sendUserMessage (idle vs steer). A synchronous throw = refusal (records stay pending). */
64
+ inject(batch: readonly FeedbackRecord[]): void;
65
+ /** True when the batch's injected message is observed as a persisted user-message entry on the branch. */
66
+ isInjected(batch: readonly FeedbackRecord[]): boolean;
67
+ isIdle(): boolean;
68
+ }
69
+
70
+ export interface FeedbackInboxHandle {
71
+ close(): void;
72
+ }
73
+
74
+ /** The lease was foreign and fresh — this session stays passive (and says so, once). */
75
+ export interface PassiveClaim {
76
+ passive: true;
77
+ reason: string;
78
+ }
79
+
80
+ export interface InboxTimers {
81
+ setTimeout(fn: () => void, ms: number): unknown;
82
+ clearTimeout(handle: unknown): void;
83
+ setInterval(fn: () => void, ms: number): unknown;
84
+ clearInterval(handle: unknown): void;
85
+ }
86
+
87
+ export interface InboxWatcher {
88
+ close(): void;
89
+ }
90
+
91
+ /** An `fs.watch` factory seam; construction may throw (degrades to poll-only). */
92
+ export type WatchFactory = (
93
+ dir: string,
94
+ onChange: () => void,
95
+ onError: (error: unknown) => void,
96
+ ) => InboxWatcher;
97
+
98
+ export interface InboxDeps {
99
+ now(): number;
100
+ timers: InboxTimers;
101
+ watch: WatchFactory;
102
+ report(severity: "info" | "warning" | "error", message: string): void;
103
+ }
104
+
105
+ export interface HunkFeedbackInbox {
106
+ open(
107
+ identity: ConsumerIdentity,
108
+ transport: FeedbackTransport,
109
+ ): FeedbackInboxHandle | PassiveClaim;
110
+ }
111
+
112
+ export function createHunkFeedbackInbox(deps: InboxDeps): HunkFeedbackInbox {
113
+ return {
114
+ open(identity, transport) {
115
+ const lockDir = hunkConsumerLockDir(identity.cwd);
116
+ const outboxPath = hunkOutboxPath(identity.cwd);
117
+ const deliveredPath = hunkDeliveredPath(identity.cwd);
118
+ const watchDir = hunkWatchDir(identity.cwd);
119
+ const { now, timers, report } = deps;
120
+
121
+ // Provenance fence (§8.58): the family is DISPOSABLE LOCAL state — a git-TRACKED entry
122
+ // under it means checkout-supplied bytes (a force-added outbox/symlink) are posing as
123
+ // live watch feedback. Refuse to open, loudly; nothing under the family is read.
124
+ const tracked = lsFiles(identity.cwd, watchDir);
125
+ if (tracked.length > 0) {
126
+ const reason =
127
+ "tracked file(s) under .perk/workflow/hunk-watch — repository-supplied feedback is " +
128
+ `refused (untrack them to re-enable the bridge): ${tracked.join(", ")}`;
129
+ report("error", reason);
130
+ return { passive: true, reason };
131
+ }
132
+
133
+ // Quarantine sweep first (leftovers from a crashed reclaimer are harmless but dirty),
134
+ // then the lease: a fresh foreign holder means this session never inspects the stream.
135
+ for (const warning of sweepQuarantine(lockDir)) report("warning", warning);
136
+ const lease = acquireLease(
137
+ lockDir,
138
+ {
139
+ runId: identity.runId,
140
+ piSessionId: identity.piSessionId,
141
+ },
142
+ now,
143
+ );
144
+ if (!lease.owned) return { passive: true, reason: lease.reason };
145
+ const token = lease.token;
146
+
147
+ // Accepted-but-unacknowledged suppression lives here too: an id whose ack append failed
148
+ // stays in this set for the rest of the session (may redeliver in a later one — §8.58).
149
+ const deliveredRead = readDeliveredIds(deliveredPath);
150
+ const delivered = deliveredRead.ids;
151
+
152
+ type Phase = "idle" | "awaiting" | "backoff";
153
+ let phase: Phase = "idle";
154
+ let dirty = false;
155
+ let closed = false;
156
+ let inFlight: { batch: readonly FeedbackRecord[]; injectedAt: number } | null = null;
157
+ /** The CURRENT backoff delay; 0 = no failures since the last observation (the only reset). */
158
+ let backoffMs = 0;
159
+ let backoffTimer: unknown = null;
160
+ let debounceTimer: unknown = null;
161
+ let pollHandle: unknown = null;
162
+ let heartbeatHandle: unknown = null;
163
+ let watcher: InboxWatcher | null = null;
164
+ const reportedOnce = new Set<string>();
165
+ const heldPlanIds = new Set<string>();
166
+
167
+ const reportOnce = (severity: "warning" | "error", message: string): void => {
168
+ if (reportedOnce.has(message)) return;
169
+ reportedOnce.add(message);
170
+ report(severity, message);
171
+ };
172
+
173
+ const close = (): void => {
174
+ if (closed) return;
175
+ closed = true;
176
+ if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
177
+ if (backoffTimer !== null) timers.clearTimeout(backoffTimer);
178
+ if (pollHandle !== null) timers.clearInterval(pollHandle);
179
+ if (heartbeatHandle !== null) timers.clearInterval(heartbeatHandle);
180
+ try {
181
+ watcher?.close();
182
+ } catch {
183
+ // disposal is best-effort
184
+ }
185
+ watcher = null;
186
+ inFlight = null;
187
+ releaseLease(lockDir, token); // removes only on token match
188
+ };
189
+
190
+ /** Fail-closed shutdown: the lease is no longer provably ours — stop, loudly, once. */
191
+ const closeFailClosed = (): void => {
192
+ reportOnce(
193
+ "error",
194
+ "feedback lease verification failed — closing the hunk feedback inbox (records stay queued for the next eligible session)",
195
+ );
196
+ close();
197
+ };
198
+
199
+ const enterBackoff = (): void => {
200
+ // Never resets on dispatch: consecutive failures double toward the cap.
201
+ backoffMs = backoffMs === 0 ? BACKOFF_BASE_MS : Math.min(backoffMs * 2, BACKOFF_CAP_MS);
202
+ phase = "backoff";
203
+ dirty = true;
204
+ backoffTimer = timers.setTimeout(() => {
205
+ backoffTimer = null;
206
+ if (closed) return;
207
+ phase = "idle";
208
+ guard(dispatch);
209
+ }, backoffMs);
210
+ };
211
+
212
+ const acknowledge = (batch: readonly FeedbackRecord[]): void => {
213
+ const at = new Date(now()).toISOString();
214
+ const acks: DeliveryAck[] = batch.map((record) => ({
215
+ schema: 1,
216
+ feedback_id: record.feedback_id,
217
+ delivered_at: at,
218
+ run_id: identity.runId,
219
+ pi_session_id: identity.piSessionId,
220
+ }));
221
+ try {
222
+ appendAcks(identity.cwd, acks);
223
+ } catch (error) {
224
+ // The message IS on the transcript — suppress same-session redelivery in memory; a
225
+ // later session may redeliver (at-least-once, stated plainly).
226
+ report(
227
+ "warning",
228
+ `could not append feedback acknowledgements (${error}) — delivery stands; the records may redeliver in a later session`,
229
+ );
230
+ }
231
+ for (const record of batch) delivered.add(record.feedback_id);
232
+ backoffMs = 0; // the ONLY reset site: transcript observation
233
+ inFlight = null;
234
+ phase = "idle";
235
+ if (dirty) dispatch();
236
+ };
237
+
238
+ /** One dispatch pass — only ever entered from `idle`. */
239
+ const dispatch = (): void => {
240
+ if (closed || phase !== "idle") return;
241
+ dirty = false;
242
+ const read = readOutbox(outboxPath);
243
+ for (const warning of read.warnings) reportOnce("warning", warning);
244
+ const pending: FeedbackRecord[] = [];
245
+ for (const record of read.records) {
246
+ if (delivered.has(record.feedback_id)) continue;
247
+ if (record.plan_id !== identity.planId) {
248
+ if (!heldPlanIds.has(record.feedback_id)) {
249
+ heldPlanIds.add(record.feedback_id);
250
+ report(
251
+ "warning",
252
+ `holding feedback ${record.feedback_id} addressed to plan ${record.plan_id} — this session implements plan ${identity.planId}`,
253
+ );
254
+ }
255
+ continue; // held: never delivered, never acked
256
+ }
257
+ pending.push(record);
258
+ }
259
+ if (pending.length === 0) return;
260
+
261
+ // Bounded batch, append order retained; the remainder re-marks dirty.
262
+ const batch: FeedbackRecord[] = [];
263
+ let bytes = 0;
264
+ for (const record of pending) {
265
+ const size = Buffer.byteLength(record.body, "utf8");
266
+ if (
267
+ batch.length > 0 &&
268
+ (batch.length >= BATCH_MAX_RECORDS || bytes + size > BATCH_MAX_BYTES)
269
+ ) {
270
+ break;
271
+ }
272
+ batch.push(record);
273
+ bytes += size;
274
+ }
275
+ if (batch.length < pending.length) dirty = true;
276
+
277
+ // The delivery fence: verify the lease immediately before every injection.
278
+ if (!verifyLease(lockDir, token)) {
279
+ closeFailClosed();
280
+ return;
281
+ }
282
+ if (transport.isInjected(batch)) {
283
+ // A prior injection survives on this branch — acknowledge without re-injecting.
284
+ acknowledge(batch);
285
+ return;
286
+ }
287
+ try {
288
+ transport.inject(batch);
289
+ } catch (error) {
290
+ report("warning", `feedback injection refused synchronously (${error}) — backing off`);
291
+ enterBackoff();
292
+ return;
293
+ }
294
+ inFlight = { batch, injectedAt: now() };
295
+ phase = "awaiting";
296
+ };
297
+
298
+ /** Wrap a background callback: report, never throw into the host session. */
299
+ const guard = (fn: () => void): void => {
300
+ try {
301
+ fn();
302
+ } catch (error) {
303
+ reportOnce("error", `hunk feedback inbox error: ${error}`);
304
+ }
305
+ };
306
+
307
+ const pollTick = (): void => {
308
+ if (closed) return;
309
+ if (phase === "awaiting" && inFlight !== null) {
310
+ const flight = inFlight;
311
+ if (transport.isInjected(flight.batch)) {
312
+ acknowledge(flight.batch);
313
+ return;
314
+ }
315
+ if (transport.isIdle() && now() - flight.injectedAt >= IN_FLIGHT_MIN_AGE_MS) {
316
+ // The session went idle again without the message landing (an abort-discarded
317
+ // steer queue or a failed turn) — demote: the records return to pending (they are
318
+ // still in the outbox and not delivered) and backoff owns the next dispatch.
319
+ inFlight = null;
320
+ enterBackoff();
321
+ }
322
+ return;
323
+ }
324
+ if (phase === "idle") {
325
+ dispatch(); // polling is the correctness path — the watcher may be dead
326
+ return;
327
+ }
328
+ dirty = true; // backoff owns the next dispatch; triggers only mark dirty
329
+ };
330
+
331
+ const onWatchEvent = (): void => {
332
+ if (closed) return;
333
+ dirty = true;
334
+ if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
335
+ debounceTimer = timers.setTimeout(() => {
336
+ debounceTimer = null;
337
+ if (closed) return;
338
+ if (phase === "idle") guard(dispatch);
339
+ }, DEBOUNCE_MS);
340
+ };
341
+
342
+ const heartbeatTick = (): void => {
343
+ if (closed) return;
344
+ try {
345
+ renewHeartbeat(lockDir, token, now);
346
+ } catch (error) {
347
+ // The pre-injection verifyLease remains the delivery fence — report, keep going.
348
+ reportOnce("warning", `feedback lease heartbeat failed: ${error}`);
349
+ }
350
+ };
351
+
352
+ for (const warning of deliveredRead.warnings) reportOnce("warning", warning);
353
+
354
+ // The immediate initial drain, then low-latency watch + poll fallback + heartbeat.
355
+ guard(dispatch);
356
+ if (closed) return { close }; // the drain closed fail-closed — install nothing
357
+ try {
358
+ watcher = deps.watch(
359
+ watchDir,
360
+ () => guard(onWatchEvent),
361
+ (error) => {
362
+ guard(() => {
363
+ reportOnce(
364
+ "warning",
365
+ `hunk feedback watcher failed (${error}) — continuing on the polling fallback`,
366
+ );
367
+ try {
368
+ watcher?.close();
369
+ } catch {
370
+ // best-effort
371
+ }
372
+ watcher = null;
373
+ });
374
+ },
375
+ );
376
+ } catch (error) {
377
+ reportOnce(
378
+ "warning",
379
+ `could not watch the feedback outbox (${error}) — continuing on the polling fallback`,
380
+ );
381
+ watcher = null;
382
+ }
383
+ pollHandle = timers.setInterval(() => guard(pollTick), POLL_MS);
384
+ heartbeatHandle = timers.setInterval(() => guard(heartbeatTick), HEARTBEAT_MS);
385
+
386
+ return { close };
387
+ },
388
+ };
389
+ }