@mgiles/perk 3.2.0 → 3.3.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 (202) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +365 -380
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -311
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
  52. package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +154 -267
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +123 -61
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/pi/v1/lifecycleGates.ts +127 -0
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +73 -27
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -21
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/prompts.ts +22 -0
  115. package/extension/substrate/registry.ts +2 -0
  116. package/extension/substrate/resolverLease.ts +5 -4
  117. package/extension/substrate/sessionData.ts +85 -152
  118. package/extension/substrate/toolGating.ts +263 -84
  119. package/extension/substrate/unifiedDiff.ts +1 -1
  120. package/extension/substrate/workflowState.ts +178 -163
  121. package/extension/substrate/worktreeResolverLock.ts +261 -0
  122. package/extension/surfaces/surfaces.ts +79 -27
  123. package/extension/waves/adversarialReviewWave.ts +87 -46
  124. package/extension/waves/blockedReports.ts +59 -0
  125. package/extension/waves/draftReviewWave.ts +42 -42
  126. package/extension/waves/laneIdentity.ts +77 -0
  127. package/extension/waves/objectiveExplorerWave.ts +24 -24
  128. package/extension/waves/prReviewWave.ts +89 -77
  129. package/extension/waves/reportWave.ts +438 -578
  130. package/extension/waves/reviewClassifierWave.ts +22 -22
  131. package/extension/waves/rpcAdapter.ts +100 -15
  132. package/extension/waves/scoutWave.ts +192 -0
  133. package/extension/waves/transport.ts +480 -0
  134. package/extension/worker/sdkAdapter.ts +494 -0
  135. package/extension/worker/stageExecution.ts +679 -0
  136. package/extension/workerMain.ts +18 -19
  137. package/package.json +6 -4
  138. package/prompts/_fixtures/live.yaml +43 -18
  139. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  140. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  142. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  143. package/prompts/contexts/objective-refinement.md +17 -0
  144. package/prompts/contexts/read-only.md +1 -1
  145. package/prompts/stages/conflict-resolution-continuation.md +9 -6
  146. package/prompts/stages/conflict-resolution.md +4 -4
  147. package/prompts/stages/objective-plan/guidance.md +2 -2
  148. package/prompts/stages/objective-plan/seed.md +9 -1
  149. package/prompts/stages/objective-reconcile-ready.md +1 -1
  150. package/prompts/stages/objective-reconcile.md +1 -1
  151. package/prompts/stages/objective-refine/seed.md +18 -0
  152. package/prompts/stages/objective-review-browser.md +4 -4
  153. package/prompts/stages/objective-sync.md +1 -1
  154. package/prompts/stages/plan-review-browser.md +4 -4
  155. package/prompts/stages/pr-review-browser/active.md +3 -4
  156. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  157. package/prompts/stages/pr-review-terminal/active.md +3 -3
  158. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  159. package/prompts/stages/pr-review.md +3 -3
  160. package/prompts/stages/stack-review-browser/stack.md +5 -6
  161. package/shared/README.md +8 -0
  162. package/shared/bindings.yaml +3 -3
  163. package/shared/contracts.md +2601 -506
  164. package/shared/fixtures/issues-table.json +130 -0
  165. package/shared/registry.yaml +13 -0
  166. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  167. package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
  168. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  169. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  170. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  171. package/extension/doors/auditWaveTools.ts +0 -352
  172. package/extension/doors/ciExecutor.ts +0 -756
  173. package/extension/doors/commitCompact.ts +0 -251
  174. package/extension/doors/dreamWaveTools.ts +0 -489
  175. package/extension/doors/learn.ts +0 -668
  176. package/extension/doors/lifecycleGates.ts +0 -207
  177. package/extension/doors/objectiveStack.ts +0 -1543
  178. package/extension/doors/prReviewDynamic.ts +0 -276
  179. package/extension/doors/ready.ts +0 -279
  180. package/extension/doors/submit.ts +0 -373
  181. package/extension/doors/submitPrReview.ts +0 -505
  182. package/extension/factories/gistAuthor.ts +0 -94
  183. package/extension/factories/gistDraft.ts +0 -265
  184. package/extension/factories/gistSave.ts +0 -251
  185. package/extension/factories/implementHere.ts +0 -116
  186. package/extension/factories/objectiveAuthor.ts +0 -98
  187. package/extension/factories/objectiveDraft.ts +0 -466
  188. package/extension/factories/objectiveSave.ts +0 -366
  189. package/extension/factories/planDraft.ts +0 -140
  190. package/extension/factories/planMode.ts +0 -205
  191. package/extension/factories/planReview.ts +0 -1237
  192. package/extension/factories/planSave.ts +0 -604
  193. package/extension/factories/planTitle.ts +0 -141
  194. package/extension/substrate/structuredOutput.ts +0 -202
  195. package/extension/waves/auditWave.ts +0 -312
  196. package/extension/waves/harvestWave.ts +0 -399
  197. package/extension/waves/learnWave.ts +0 -155
  198. package/extension/waves/memoryAdapter.ts +0 -139
  199. package/extension/waves/prReviewDynamicWave.ts +0 -777
  200. package/extension/worker/readOnlySession.ts +0 -294
  201. package/extension/worker/worker.ts +0 -899
  202. package/prompts/stages/pr-review-dynamic.md +0 -7
package/README.md CHANGED
@@ -110,6 +110,11 @@ just ci
110
110
  managed by [prek](https://prek.j178.dev) and runs `ruff check` on staged Python. Re-run
111
111
  `just hooks` after a fresh clone if hooks are missing.
112
112
 
113
+ Focused Python selections while iterating — `just test-py-fast` (`-m "not slow"`) and
114
+ `just test-py-slow` (`-m slow`) — and how to time a tier are documented in
115
+ [docs/developers/testing.md](docs/developers/testing.md); `just test`, `just ci`, GitHub CI and
116
+ perk's in-session `run_ci` always run the whole Python suite.
117
+
113
118
  Without `just`: `uv run …` for Python (`uv run perk init`, `uv run pytest`,
114
119
  `uv run ruff check perk tests`, `uv run ty check`) and `npm run …` for TypeScript
115
120
  (`npm run lint`, `npm run typecheck`).
@@ -0,0 +1,198 @@
1
+ // The gist working-draft feature: the typed `GistDraft` model, its schema_version-1 JSON
2
+ // encode/decode (JSON is storage/transport only — the review surface is `renderGistDraft`'s
3
+ // markdown, never raw bytes), and the two draft operations over the WorkflowSession seam.
4
+ //
5
+ // Carve-out doctrine: the artifact name is the fixed constant `GIST_DRAFT_ARTIFACT` and every
6
+ // byte flows through the session seam (file + verified `session_artifacts` pointer), so the only
7
+ // thing the draft ops can ever touch is the one working-gist artifact in the current run's data
8
+ // dir (gitignored scratch). A revision is a WHOLE-VALUE replacement — no revision ids, no
9
+ // compare-and-swap claims the backing cannot prove.
10
+ //
11
+ // Format doctrine: the artifact carries `{schema_version, title?, scope?, prose}` — deliberately
12
+ // light: a gist is a problem-space statement of intent with no structured roadmap
13
+ // (contracts.md §8.41).
14
+
15
+ import type { SessionArtifactReceipt, WorkflowSession } from "../../session/workflowSession.ts";
16
+
17
+ /** The registry stage id of the gist-authoring session (shared with planMode's defer check). */
18
+ export const GIST_AUTHOR_STAGE = "gist-author";
19
+
20
+ /** The gist consumption tiers (`scope` — contracts.md §8.41). */
21
+ export const GIST_SCOPES = ["plan", "objective"] as const;
22
+
23
+ export type GistScope = (typeof GIST_SCOPES)[number];
24
+
25
+ /** The fixed working-gist artifact name (one JSON file: the prose + the optional scope hint). */
26
+ export const GIST_DRAFT_ARTIFACT = "gist-draft.json";
27
+
28
+ /** The validated working-gist draft shape. */
29
+ export interface GistDraft {
30
+ title?: string;
31
+ scope?: GistScope;
32
+ prose: string;
33
+ }
34
+
35
+ /**
36
+ * Serialize a working gist as the schema_version-1 JSON artifact: deterministic key order via
37
+ * the explicit literal; `title`/`scope` omitted when blank — byte-identical to what the artifact
38
+ * always carried. Pure; never throws.
39
+ */
40
+ export function encodeGistDraft(draft: {
41
+ prose: string;
42
+ title?: string;
43
+ scope?: GistScope;
44
+ }): string {
45
+ const title = draft.title?.trim();
46
+ const payload = {
47
+ schema_version: 1,
48
+ ...(title ? { title } : {}),
49
+ ...(draft.scope ? { scope: draft.scope } : {}),
50
+ prose: draft.prose,
51
+ };
52
+ return `${JSON.stringify(payload, null, 2)}\n`;
53
+ }
54
+
55
+ /** The classified decode outcome — `problem` carries the exact refusal bytes for edge rendering. */
56
+ export type DecodeGistDraftResult = { ok: true; draft: GistDraft } | { ok: false; problem: string };
57
+
58
+ /**
59
+ * Decode + validate working-gist artifact bytes. A CLASSIFIED refusal (never a warn+null
60
+ * fallback) on malformed JSON, a non-object payload, an unsupported `schema_version`, or blank
61
+ * prose — the same refusal taxonomy, with the problem bytes rendered by the consuming edge.
62
+ * `title` is kept only when a non-blank string; `scope` only when a member of the enum (an
63
+ * unknown scope degrades to absent, never poisons the draft). Never throws.
64
+ */
65
+ export function decodeGistDraft(content: string): DecodeGistDraftResult {
66
+ const refuse = (why: string): { ok: false; problem: string } => ({
67
+ ok: false,
68
+ problem: `${GIST_DRAFT_ARTIFACT} ${why} — refusing the draft`,
69
+ });
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(content);
73
+ } catch {
74
+ return refuse("is not valid JSON");
75
+ }
76
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
77
+ return refuse("is not a JSON object");
78
+ }
79
+ const payload = parsed as Record<string, unknown>;
80
+ if (payload.schema_version !== 1) {
81
+ return refuse(`has an unsupported schema_version (${JSON.stringify(payload.schema_version)})`);
82
+ }
83
+ const prose = payload.prose;
84
+ if (typeof prose !== "string" || !prose.trim()) {
85
+ return refuse("has no prose");
86
+ }
87
+ const title =
88
+ typeof payload.title === "string" && payload.title.trim() ? payload.title : undefined;
89
+ const scope =
90
+ typeof payload.scope === "string" && (GIST_SCOPES as readonly string[]).includes(payload.scope)
91
+ ? (payload.scope as GistScope)
92
+ : undefined;
93
+ return {
94
+ ok: true,
95
+ draft: {
96
+ ...(title !== undefined ? { title } : {}),
97
+ ...(scope !== undefined ? { scope } : {}),
98
+ prose,
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Render the draft as the markdown review surface (JSON is storage/transport only — contracts
105
+ * §8.1): the optional `# title` heading, a `Scope:` line when the hint is set, and the prose
106
+ * verbatim. Pure; never throws.
107
+ */
108
+ export function renderGistDraft(draft: GistDraft): string {
109
+ let out = "";
110
+ if (draft.title) out += `# ${draft.title}\n\n`;
111
+ if (draft.scope) out += `Scope: ${draft.scope}\n\n`;
112
+ return out + draft.prose;
113
+ }
114
+
115
+ /**
116
+ * The revise outcome. `rejected` splits by `reason` so the adapter renders the exact failure
117
+ * taxonomy it always had: `blank_prose` (input refused), `no_identity` (no session), and
118
+ * `write_refused` (the seam refused before any effect); `unverified` means an effect may have
119
+ * landed but the read-back proof failed. `problem` carries the caller-facing message bytes.
120
+ */
121
+ export type ReviseGistDraftResult =
122
+ | { status: "revised"; receipt: SessionArtifactReceipt; bytes: number }
123
+ | { status: "unchanged"; receipt: SessionArtifactReceipt; bytes: number }
124
+ | { status: "rejected"; reason: "blank_prose" | "no_identity" | "write_refused"; problem: string }
125
+ | { status: "unverified"; problem: string };
126
+
127
+ /**
128
+ * Rewrite the working gist draft (a whole-value replacement) through the session seam.
129
+ * Diagnostic precedence preserved: blank prose is refused FIRST, missing identity second
130
+ * (the identity-optional session classifies `runId: null` — an identity-less caller still
131
+ * opens), then the verified artifact write. Never throws.
132
+ */
133
+ export function reviseGistDraft(
134
+ input: { prose: string; title?: string; scope?: GistScope },
135
+ session: WorkflowSession,
136
+ ): ReviseGistDraftResult {
137
+ if (!input.prose.trim()) {
138
+ return {
139
+ status: "rejected",
140
+ reason: "blank_prose",
141
+ problem: "no gist prose to write (pass the full working draft)",
142
+ };
143
+ }
144
+ if (session.runId === null) {
145
+ return {
146
+ status: "rejected",
147
+ reason: "no_identity",
148
+ problem: "session has no run_id — cannot write the gist-draft artifact",
149
+ };
150
+ }
151
+ const content = encodeGistDraft(input);
152
+ const bytes = Buffer.byteLength(content, "utf8");
153
+ const written = session.writeArtifact(GIST_DRAFT_ARTIFACT, content);
154
+ switch (written.status) {
155
+ case "applied":
156
+ return { status: "revised", receipt: written.receipt, bytes };
157
+ case "unchanged":
158
+ return { status: "unchanged", receipt: written.receipt, bytes };
159
+ case "rejected":
160
+ return {
161
+ status: "rejected",
162
+ reason: "write_refused",
163
+ problem: `could not write the ${GIST_DRAFT_ARTIFACT} artifact (see warnings)`,
164
+ };
165
+ case "unverified":
166
+ return {
167
+ status: "unverified",
168
+ problem: `could not write the ${GIST_DRAFT_ARTIFACT} artifact (see warnings)`,
169
+ };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The classified resume outcome: a refused draft is a fail-closed STOP at every consumer —
175
+ * it never takes the no-draft fallbacks' side effects (gate exit, driven turn). `raw` is the
176
+ * artifact bytes the draft was decoded from — the reviewed-bytes baseline; consumers that
177
+ * render and fence must use this one read for both (a second read could be newer than what
178
+ * the human saw).
179
+ */
180
+ export type ResumeGistDraftResult =
181
+ | { kind: "valid"; draft: GistDraft; raw: string }
182
+ | { kind: "absent" }
183
+ | { kind: "refused"; problem: string };
184
+
185
+ /**
186
+ * Resume the working gist draft from the session, classified: seam `absent` → `absent` (the
187
+ * genuine no-draft arm); seam `invalid` → `refused` carrying the seam's problem (a corrupted
188
+ * artifact is truthfully rendered at the edge — the seam's own stderr tier is untouched); a
189
+ * decodable-but-refused payload → `refused` with the decoder's problem. Never throws.
190
+ */
191
+ export function resumeGistDraft(session: WorkflowSession): ResumeGistDraftResult {
192
+ const read = session.readArtifact(GIST_DRAFT_ARTIFACT);
193
+ if (read.status === "absent") return { kind: "absent" };
194
+ if (read.status === "invalid") return { kind: "refused", problem: read.problem };
195
+ const decoded = decodeGistDraft(read.content);
196
+ if (!decoded.ok) return { kind: "refused", problem: decoded.problem };
197
+ return { kind: "valid", draft: decoded.draft, raw: read.content };
198
+ }
@@ -0,0 +1,46 @@
1
+ // The gist feature's model-facing prose units: the two tool-guideline arrays, the
2
+ // gist-authoring context content builder, and the injection marker/customType constants.
3
+ // Prose-unit MEANING and ORDER live here (the feature); the v1 adapter only PLACES these units
4
+ // in Pi registration fields and event payloads (module-contracts.md's prose split). Prompting,
5
+ // NOT enforcement — the read-only tool gate is the enforcement.
6
+
7
+ import { render } from "../../substrate/prompts.ts";
8
+
9
+ /** The gist-authoring context customType (distinct from planMode's `perk:plan-context`). */
10
+ export const GIST_AUTHOR_CONTEXT_TYPE = "perk:gist-author-context";
11
+
12
+ /** The injected gist-authoring context's identity marker (the strip + dedup key). */
13
+ export const GIST_AUTHOR_MARKER = "[GIST AUTHORING]";
14
+
15
+ /**
16
+ * The gist-authoring session context: live state + pointers only (contracts.md §8.57 — the flow
17
+ * is stated by the launch statement, the detail by the `perk-gist-author` skill). It names the
18
+ * working-draft artifact (`gist_draft`), the review tool (`plan_review`), and the bound skill;
19
+ * it never restates the flow.
20
+ */
21
+ export const GIST_AUTHORING_CONTEXT = render("contexts/gist-authoring.md", {
22
+ marker: GIST_AUTHOR_MARKER,
23
+ });
24
+
25
+ /**
26
+ * Build the full gist-authoring injection, appending the project-config authoring addendum when
27
+ * present. Pure over the addendum — the ADAPTER loads `[workflow] plan_authoring` per event and
28
+ * passes the value in (the narrow-views doctrine).
29
+ */
30
+ export function gistAuthoringContextContent(addendum: string | undefined): string {
31
+ return addendum ? `${GIST_AUTHORING_CONTEXT}\n\n${addendum.trim()}` : GIST_AUTHORING_CONTEXT;
32
+ }
33
+
34
+ /** The `gist_draft` tool guidelines (verbatim prose units; the adapter places them). */
35
+ export const GIST_DRAFT_TOOL_GUIDELINES = [
36
+ "Call gist_draft to persist the current working gist as you author or revise it; pass the FULL prose each time (it rewrites the whole draft).",
37
+ "gist_draft never saves to the issue backend and never ends the turn — gist_save//gist-save remain the canonical save surface.",
38
+ "Pass gist_draft's `scope` only once the consumption tier is settled: `plan` for plan-sized intent, `objective` for objective-sized intent.",
39
+ ];
40
+
41
+ /** The `gist_save` tool guidelines (verbatim prose units; the adapter places them). */
42
+ export const GIST_SAVE_TOOL_GUIDELINES = [
43
+ "Use gist_save only after the gist says what it means; it creates the tracked gist in the issue backend and ends the turn.",
44
+ "Pass gist_save the statement-of-intent PROSE in `prose` — problem-focused, with at most high-level solution leanings; no implementation steps or roadmap.",
45
+ "Pass gist_save's `scope` only once the consumption tier is settled (plan or objective); omit it to keep the pre-seeded/default scope.",
46
+ ];
@@ -0,0 +1,133 @@
1
+ // The gist review feature: the `GistDraftReviewer` role (one production adapter per review
2
+ // backend — plannotator bridge or first-party editor, both built in pi/v1 — plus a scripted fake
3
+ // in the tests) and the one-entry `reviewGist` operation.
4
+ //
5
+ // Ordering is the review door's exact discipline: resume-the-draft FIRST (absent → `noDraft` —
6
+ // the draft artifact is the SOLE review source, never a param, never the transcript), render the
7
+ // markdown surface, review, then route the verdict — an approval carrying direct edits SKIPS the
8
+ // save and returns one revise round (rendered edits cannot be folded back into the structured
9
+ // draft mechanically; the gate stays untouched); a plain approval re-reads the artifact through
10
+ // `gistApprovalSave` (the save source is the artifact, never the reviewed bytes). Headless
11
+ // detection stays in the adapter — the feature never sees `hasUI`.
12
+
13
+ import type { WorkflowSession } from "../../session/workflowSession.ts";
14
+ import type { ApprovalGate } from "../review/approvalGate.ts";
15
+ import { renderGistDraft, resumeGistDraft } from "./draft.ts";
16
+ import { type GistApprovalSaveOutcome, type GistBackend, gistApprovalSave } from "./save.ts";
17
+
18
+ /**
19
+ * The reviewer's verdict on the rendered draft. An approval carrying reviewer edits of the
20
+ * rendered markdown is its OWN variant (`approvedDirectEdits`) with `feedback` required — the
21
+ * edits ARE the feedback, so an edits-without-feedback value is unrepresentable and can never
22
+ * fall through to the save path. The backend adapter translates its own vocabulary (e.g.
23
+ * plannotator's `# Direct Edits` feedback section) into this variant.
24
+ */
25
+ export type GistReviewOutcome =
26
+ | { status: "approved"; feedback?: string; reviewId?: string }
27
+ | { status: "approvedDirectEdits"; feedback: string; reviewId?: string }
28
+ | { status: "denied"; feedback?: string; reviewId?: string }
29
+ | { status: "dismissed" }
30
+ | { status: "aborted" }
31
+ | { status: "unavailable"; warning: string };
32
+
33
+ /** The reviewer role: judge the RENDERED markdown surface (never raw artifact JSON). */
34
+ export interface GistDraftReviewer {
35
+ review(rendered: string, signal?: AbortSignal): Promise<GistReviewOutcome>;
36
+ }
37
+
38
+ /** The one-entry review outcome — each arm carries exactly what its caller renders.
39
+ * `refusedDraft` (pre-review) and `approvedRefusedDraft` (the approval-time race) are the
40
+ * fail-closed stops for an invalid artifact: nothing reviewed/saved, the gate untouched. */
41
+ export type ReviewGistResult =
42
+ | { status: "noDraft" }
43
+ | { status: "refusedDraft"; problem: string }
44
+ | { status: "directEditsRevise"; feedback: string; reviewId?: string }
45
+ | {
46
+ status: "approvedSaved";
47
+ save: Extract<GistApprovalSaveOutcome, { status: "saved" }>;
48
+ feedback?: string;
49
+ reviewId?: string;
50
+ }
51
+ | {
52
+ status: "approvedSaveFailed";
53
+ save: Extract<GistApprovalSaveOutcome, { status: "save-failed" }>;
54
+ feedback?: string;
55
+ reviewId?: string;
56
+ }
57
+ | { status: "approvedNoDraft"; feedback?: string; reviewId?: string }
58
+ | { status: "approvedRefusedDraft"; problem: string; feedback?: string; reviewId?: string }
59
+ | { status: "denied"; feedback?: string; reviewId?: string }
60
+ | { status: "dismissed" }
61
+ | { status: "aborted" }
62
+ | { status: "unavailable"; warning: string };
63
+
64
+ /**
65
+ * Review the working gist draft end-to-end: resume → render → review → route. An approval with
66
+ * `directEdits` (and feedback to fold) returns the revise round with NOTHING saved and the gate
67
+ * untouched; a plain approval runs `gistApprovalSave` (which re-reads the artifact at save time
68
+ * — `approvedNoDraft` is the defensive vanished-between-reads arm; `approvedRefusedDraft` its
69
+ * corrupted-between-reads sibling, feedback/reviewId preserved across the race). Never throws.
70
+ */
71
+ export async function reviewGist(
72
+ deps: {
73
+ session: WorkflowSession;
74
+ reviewer: GistDraftReviewer;
75
+ backend: GistBackend;
76
+ gate: ApprovalGate;
77
+ },
78
+ signal?: AbortSignal,
79
+ ): Promise<ReviewGistResult> {
80
+ if (signal?.aborted) return { status: "aborted" };
81
+ const resumed = resumeGistDraft(deps.session);
82
+ if (resumed.kind === "absent") return { status: "noDraft" };
83
+ if (resumed.kind === "refused") return { status: "refusedDraft", problem: resumed.problem };
84
+ const rendered = renderGistDraft(resumed.draft);
85
+ const outcome = await deps.reviewer.review(rendered, signal);
86
+ if (signal?.aborted) return { status: "aborted" };
87
+ return completeGistReview(outcome, () => gistApprovalSave(deps));
88
+ }
89
+
90
+ /** Subject policy only: callers authorize effects before entering this completion seam. */
91
+ export async function completeGistReview(
92
+ outcome: GistReviewOutcome,
93
+ approvalSave: () => Promise<GistApprovalSaveOutcome>,
94
+ ): Promise<ReviewGistResult> {
95
+ if (outcome.status === "approvedDirectEdits") {
96
+ return {
97
+ status: "directEditsRevise",
98
+ feedback: outcome.feedback,
99
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
100
+ };
101
+ }
102
+ if (outcome.status === "approved") {
103
+ const carried = {
104
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
105
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
106
+ };
107
+ const save = await approvalSave();
108
+ switch (save.status) {
109
+ case "saved":
110
+ return { status: "approvedSaved", save, ...carried };
111
+ case "save-failed":
112
+ return { status: "approvedSaveFailed", save, ...carried };
113
+ case "no-draft":
114
+ return { status: "approvedNoDraft", ...carried };
115
+ case "refused-draft":
116
+ return { status: "approvedRefusedDraft", problem: save.problem, ...carried };
117
+ }
118
+ }
119
+ switch (outcome.status) {
120
+ case "denied":
121
+ return {
122
+ status: "denied",
123
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
124
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
125
+ };
126
+ case "dismissed":
127
+ return { status: "dismissed" };
128
+ case "aborted":
129
+ return { status: "aborted" };
130
+ case "unavailable":
131
+ return { status: "unavailable", warning: outcome.warning };
132
+ }
133
+ }
@@ -0,0 +1,118 @@
1
+ // The gist save feature: the narrow exterior `GistBackend` port (one production adapter — the
2
+ // `perk gist create` cold door in pi/v1 — plus one deterministic fake in the tests: the port
3
+ // admission rule), the `saveGist` operation, and the shared APPROVED-review → save orchestration
4
+ // `gistApprovalSave` (the exported name contracts.md §8.41 pins).
5
+ //
6
+ // Unlike the plan/objective siblings there is NO session linkage after the save — nothing
7
+ // consumes a gist in-session (adoption happens later via the cold doors), so a successful save
8
+ // just carries the backend's id/url/scope facts for the caller to relay.
9
+
10
+ import type { WorkflowSession } from "../../session/workflowSession.ts";
11
+ import { type ApprovalGate, saveThroughApprovalGate } from "../review/approvalGate.ts";
12
+ import { GIST_SCOPES, type GistScope, resumeGistDraft } from "./draft.ts";
13
+
14
+ /** The backend save facts (`id` is the opaque string gist id — contracts §8.21). */
15
+ export type GistBackendSaveResult =
16
+ | { status: "saved"; id: string; url: string; existed: boolean | null; scope: string | null }
17
+ | { status: "failed"; message: string; errorType: string };
18
+
19
+ /**
20
+ * The narrow exterior port the save operation writes through. `runId: null` means the caller has
21
+ * no session identity — the backend omits its run linkage (an identity-less save keeps working).
22
+ */
23
+ export interface GistBackend {
24
+ save(req: {
25
+ prose: string;
26
+ title?: string;
27
+ scope?: GistScope;
28
+ runId: string | null;
29
+ }): Promise<GistBackendSaveResult>;
30
+ }
31
+
32
+ /**
33
+ * The save outcome: the backend facts, or a typed refusal/failure (message bytes caller-facing).
34
+ * Deliberately the SAME union as the backend result — `saveGist` adds the local validation
35
+ * refusals on the shared `failed` variant and otherwise returns the backend value unchanged, so
36
+ * a second hand-mirrored vocabulary would only invite drift. Alias, not copy.
37
+ */
38
+ export type SaveGistOutcome = GistBackendSaveResult;
39
+
40
+ /**
41
+ * The single save operation both surfaces call: validate the prose/scope (the exact refusals the
42
+ * save always had), then write through the backend port. The backend owns scope resolution
43
+ * beyond the explicit value (launch-handoff seed, default). Never throws.
44
+ */
45
+ export async function saveGist(
46
+ input: { prose: string; title?: string; scope?: GistScope },
47
+ deps: { backend: GistBackend; runId: string | null },
48
+ ): Promise<SaveGistOutcome> {
49
+ const prose = input.prose.trim();
50
+ if (!prose) {
51
+ return {
52
+ status: "failed",
53
+ message: "no gist prose to save (draft the gist first)",
54
+ errorType: "invalid_input",
55
+ };
56
+ }
57
+ if (input.scope !== undefined && !(GIST_SCOPES as readonly string[]).includes(input.scope)) {
58
+ return {
59
+ status: "failed",
60
+ message: "scope must be plan or objective",
61
+ errorType: "invalid_input",
62
+ };
63
+ }
64
+ return deps.backend.save({
65
+ prose,
66
+ ...(input.title !== undefined ? { title: input.title } : {}),
67
+ ...(input.scope !== undefined ? { scope: input.scope } : {}),
68
+ runId: deps.runId,
69
+ });
70
+ }
71
+
72
+ /** The approval→save orchestration outcome (the gist `ApprovalSaveOutcome`). `refused-draft`
73
+ * is the fail-closed stop for an invalid artifact: nothing saved, the gate never touched —
74
+ * distinct from `no-draft` (the genuine draft-less fallback arm). */
75
+ export type GistApprovalSaveOutcome =
76
+ | { status: "no-draft" }
77
+ | { status: "refused-draft"; problem: string }
78
+ | { status: "saved"; save: Extract<SaveGistOutcome, { status: "saved" }>; gateExited: boolean }
79
+ | {
80
+ status: "save-failed";
81
+ save: Extract<SaveGistOutcome, { status: "failed" }>;
82
+ gateExited: false;
83
+ };
84
+
85
+ /**
86
+ * The shared APPROVED-review → save orchestration (an APPROVED `plan_review` gist arm and the
87
+ * manual `/gist-save` failsafe both run THIS). Flow: re-read the draft artifact at save time
88
+ * (`resumeGistDraft` — never the rendered markdown, never in-hand bytes) → `saveGist` through
89
+ * `saveThroughApprovalGate` (the D1a invariant: snapshot before the save; exit only after a
90
+ * successful save while read-only; a failed save leaves the gate ON). No draft → `no-draft`
91
+ * (nothing saved, the gate untouched); a REFUSED draft → `refused-draft` before the gate
92
+ * snapshot (fail-closed stop — `gateExited` semantics never arise). Title precedence:
93
+ * the explicit override (`/gist-save [title]` — a pinned behavior) wins over the draft's; scope
94
+ * is always the draft's.
95
+ */
96
+ export async function gistApprovalSave(
97
+ deps: { session: WorkflowSession; backend: GistBackend; gate: ApprovalGate },
98
+ opts: { title?: string } = {},
99
+ ): Promise<GistApprovalSaveOutcome> {
100
+ const resumed = resumeGistDraft(deps.session);
101
+ if (resumed.kind === "absent") return { status: "no-draft" };
102
+ if (resumed.kind === "refused") return { status: "refused-draft", problem: resumed.problem };
103
+ const draft = resumed.draft;
104
+ const { outcome: save, gateExited } = await saveThroughApprovalGate(deps.gate, () =>
105
+ saveGist(
106
+ {
107
+ prose: draft.prose,
108
+ ...(opts.title !== undefined || draft.title !== undefined
109
+ ? { title: opts.title ?? draft.title }
110
+ : {}),
111
+ ...(draft.scope !== undefined ? { scope: draft.scope } : {}),
112
+ },
113
+ { backend: deps.backend, runId: deps.session.runId },
114
+ ),
115
+ );
116
+ if (save.status !== "saved") return { status: "save-failed", save, gateExited: false };
117
+ return { status: "saved", save, gateExited };
118
+ }