@mgiles/perk 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +68 -44
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -13,13 +13,26 @@
13
13
  // the converged content actually *reached* the prompt. That closes perk's two-plane blind spot —
14
14
  // doctor checks disk, selfcheck checks the prompt.
15
15
  //
16
+ // The same report additionally carries a per-surface payload CENSUS — derived counts/chars for
17
+ // `appendSystemPrompt`, `contextFiles`, the skills catalog section, the active tool definitions,
18
+ // and perk-injected `custom_message` branch context. Report-only: the census never affects the
19
+ // ok/level verdict (contracts.md §8.7).
20
+ //
16
21
  // Sensitivity: `getSystemPromptOptions()` exposes the full system-prompt construction inputs. This
17
- // module logs ONLY derived booleans/counts (never the raw prompt text). See docs/learned/pi/.
22
+ // module logs ONLY derived output identifiers (paths, skill/tool names, source strings,
23
+ // customTypes) and counts/chars; never prompt/content text. See docs/learned/pi/.
18
24
 
19
25
  import { existsSync, readFileSync } from "node:fs";
20
26
  import { join } from "node:path";
21
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ type ExtensionAPI,
29
+ formatSkillsForPrompt,
30
+ type Skill,
31
+ type ToolInfo,
32
+ } from "@earendil-works/pi-coding-agent";
33
+ import { BINDING_HEADER } from "../substrate/bindingDelivery.ts";
22
34
  import { registerPerkCommand } from "../substrate/command.ts";
35
+ import { branchOf } from "../substrate/workflowState.ts";
23
36
  import { report as reportTo } from "../surfaces/report.ts";
24
37
 
25
38
  /** Project-scoped ambient routing index, relative to the repo root. */
@@ -28,10 +41,15 @@ export const AMBIENT_INDEX_REL_PATH = join(".pi", "APPEND_SYSTEM.md");
28
41
  /** The managed `AGENTS.md` block marker (cross-plane: written by `perk init`, read here). */
29
42
  export const MANAGED_AGENTS_MARKER = "<!-- BEGIN perk managed -->";
30
43
 
31
- /** The narrow slice of `BuildSystemPromptOptions` the verifier probes. */
44
+ /** The slice of `BuildSystemPromptOptions` the verifier + census probe (all optional). */
32
45
  export interface SystemPromptProbeInput {
46
+ customPrompt?: string;
33
47
  appendSystemPrompt?: string;
34
48
  contextFiles?: { path: string; content: string }[];
49
+ skills?: Skill[];
50
+ selectedTools?: string[];
51
+ toolSnippets?: Record<string, string>;
52
+ promptGuidelines?: string[];
35
53
  }
36
54
 
37
55
  /** Read the on-disk ambient routing index (`.pi/APPEND_SYSTEM.md`), or `null` if absent. */
@@ -130,6 +148,214 @@ export function buildSelfcheckReport(input: {
130
148
  return { version, sharedOk, ambient, agents, ok, summary, level: ok ? "info" : "warning" };
131
149
  }
132
150
 
151
+ // ---------------------------------------------------------------------------
152
+ // The per-surface payload census (contracts.md §8.7). Pure builders over the same
153
+ // `getSystemPromptOptions()` slice plus the tool registry and the session branch. All counts are
154
+ // `string.length` char counts (rendered with a `c` suffix). Identifiers (paths, skill/tool names,
155
+ // source strings, customTypes) and counts/chars only — never prompt/content text.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** Derived per-surface counts for the system-prompt construction inputs. */
159
+ export interface PromptCensus {
160
+ /** `customPrompt` length; `null` = pi's built-in default is in use (not measurable here). */
161
+ basePromptChars: number | null;
162
+ appendChars: number;
163
+ contextFiles: { count: number; totalChars: number; files: { path: string; chars: number }[] };
164
+ /** `hidden` = skills with `disableModelInvocation`; `promptSectionChars` is the exact
165
+ * system-prompt contribution (pi's own exported `formatSkillsForPrompt`). */
166
+ skills: { visible: number; hidden: number; promptSectionChars: number };
167
+ toolGuidelineChars: number;
168
+ toolSnippetChars: number;
169
+ }
170
+
171
+ /** Census the system-prompt surfaces from the probed options (undefined → zeros). */
172
+ export function promptCensus(options: SystemPromptProbeInput | undefined): PromptCensus {
173
+ const files = (options?.contextFiles ?? []).map((f) => ({
174
+ path: f.path,
175
+ chars: f.content.length,
176
+ }));
177
+ const skills = options?.skills ?? [];
178
+ const hidden = skills.filter((s) => s.disableModelInvocation).length;
179
+ let guidelineChars = 0;
180
+ for (const g of options?.promptGuidelines ?? []) guidelineChars += g.length;
181
+ let snippetChars = 0;
182
+ for (const s of Object.values(options?.toolSnippets ?? {})) snippetChars += s.length;
183
+ return {
184
+ basePromptChars: options?.customPrompt === undefined ? null : options.customPrompt.length,
185
+ appendChars: (options?.appendSystemPrompt ?? "").length,
186
+ contextFiles: {
187
+ count: files.length,
188
+ totalChars: files.reduce((sum, f) => sum + f.chars, 0),
189
+ files,
190
+ },
191
+ skills: {
192
+ visible: skills.length - hidden,
193
+ hidden,
194
+ // formatSkillsForPrompt filters disableModelInvocation skills itself, so passing the full
195
+ // list measures exactly the visible skills' prompt-section contribution.
196
+ promptSectionChars: formatSkillsForPrompt(skills).length,
197
+ },
198
+ toolGuidelineChars: guidelineChars,
199
+ toolSnippetChars: snippetChars,
200
+ };
201
+ }
202
+
203
+ /** Derived counts for the tool-definitions payload (active tools = the per-request payload). */
204
+ export interface ToolsCensus {
205
+ active: number;
206
+ all: number;
207
+ /** Sum of `JSON.stringify({name, description, parameters})` over ACTIVE tools. */
208
+ schemaChars: number;
209
+ /** Active tools grouped by `sourceInfo.source`, sorted by source string (stable output). */
210
+ bySource: { source: string; active: number; schemaChars: number }[];
211
+ }
212
+
213
+ /** Census the tool registry: active vs registered counts + per-source schema chars. */
214
+ export function toolsCensus(allTools: ToolInfo[], activeNames: string[]): ToolsCensus {
215
+ const activeSet = new Set(activeNames);
216
+ const bySource = new Map<string, { active: number; schemaChars: number }>();
217
+ let schemaChars = 0;
218
+ for (const tool of allTools) {
219
+ if (!activeSet.has(tool.name)) continue;
220
+ const chars = JSON.stringify({
221
+ name: tool.name,
222
+ description: tool.description,
223
+ parameters: tool.parameters,
224
+ }).length;
225
+ schemaChars += chars;
226
+ const row = bySource.get(tool.sourceInfo.source) ?? { active: 0, schemaChars: 0 };
227
+ row.active += 1;
228
+ row.schemaChars += chars;
229
+ bySource.set(tool.sourceInfo.source, row);
230
+ }
231
+ return {
232
+ active: activeNames.length,
233
+ all: allTools.length,
234
+ schemaChars,
235
+ bySource: [...bySource.entries()]
236
+ .map(([source, row]) => ({ source, ...row }))
237
+ .sort((a, b) => (a.source < b.source ? -1 : a.source > b.source ? 1 : 0)),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * The structural slice of a session entry the branch census reads. Injected contexts persist as
243
+ * `type: "custom_message"` entries carrying `content` — distinct from `type: "custom"` state
244
+ * entries (workflow state, checkpoints), which the census must NOT count as context.
245
+ */
246
+ export interface CensusBranchEntry {
247
+ type: string;
248
+ customType?: string;
249
+ content?: unknown;
250
+ }
251
+
252
+ /** Derived counts for perk-injected branch context (perk's customTypes share the `perk:` prefix). */
253
+ export interface BranchContextCensus {
254
+ entries: number;
255
+ /** `custom_message` entries whose customType starts with `perk:`, sorted by customType. */
256
+ perkContexts: { customType: string; copies: number; totalChars: number }[];
257
+ /** `custom_message` entries without the `perk:` prefix (borrowed packages). */
258
+ otherCustomMessages: { copies: number; totalChars: number };
259
+ /** Entries (any type) whose JSON form carries `BINDING_HEADER` — the cold↔warm dedup marker. */
260
+ bindingHeaderCopies: number;
261
+ }
262
+
263
+ /** Char count of a `custom_message` entry's content — string, `text`-part array, or JSON size. */
264
+ function contentChars(content: unknown): number {
265
+ if (content === undefined) return 0;
266
+ if (typeof content === "string") return content.length;
267
+ if (Array.isArray(content)) {
268
+ let sum = 0;
269
+ for (const part of content) {
270
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
271
+ const text = (part as { text?: unknown }).text;
272
+ if (typeof text === "string") sum += text.length;
273
+ }
274
+ return sum;
275
+ }
276
+ return JSON.stringify(content).length;
277
+ }
278
+
279
+ /** Census the session branch's injected context (counts/chars only — never message content). */
280
+ export function branchContextCensus(entries: readonly CensusBranchEntry[]): BranchContextCensus {
281
+ const perk = new Map<string, { copies: number; totalChars: number }>();
282
+ const other = { copies: 0, totalChars: 0 };
283
+ let bindingHeaderCopies = 0;
284
+ for (const entry of entries) {
285
+ if (JSON.stringify(entry).includes(BINDING_HEADER)) bindingHeaderCopies += 1;
286
+ if (entry.type !== "custom_message") continue;
287
+ const chars = contentChars(entry.content);
288
+ const customType = entry.customType ?? "";
289
+ if (customType.startsWith("perk:")) {
290
+ const row = perk.get(customType) ?? { copies: 0, totalChars: 0 };
291
+ row.copies += 1;
292
+ row.totalChars += chars;
293
+ perk.set(customType, row);
294
+ } else {
295
+ other.copies += 1;
296
+ other.totalChars += chars;
297
+ }
298
+ }
299
+ return {
300
+ entries: entries.length,
301
+ perkContexts: [...perk.entries()]
302
+ .map(([customType, row]) => ({ customType, ...row }))
303
+ .sort((a, b) => (a.customType < b.customType ? -1 : a.customType > b.customType ? 1 : 0)),
304
+ otherCustomMessages: other,
305
+ bindingHeaderCopies,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Render the census as a fixed multi-line block. The line grammar (the `census:` /
311
+ * `append-system-prompt:` / `context-files:` / `skills:` / `tools:` / `per source:` / `branch:` /
312
+ * `perk contexts:` keys) is stable — the closing audit diffs against these exact keys.
313
+ */
314
+ export function renderCensus(
315
+ prompt: PromptCensus,
316
+ tools: ToolsCensus,
317
+ branch: BranchContextCensus,
318
+ ): string {
319
+ const lines: string[] = ["census:"];
320
+ lines.push(
321
+ prompt.basePromptChars === null
322
+ ? " base-prompt: pi-default (not measured)"
323
+ : ` base-prompt: custom ${prompt.basePromptChars}c`,
324
+ );
325
+ lines.push(` append-system-prompt: ${prompt.appendChars}c`);
326
+ const fileList = prompt.contextFiles.files.map((f) => `${f.path}=${f.chars}c`).join(", ");
327
+ lines.push(
328
+ ` context-files: ${prompt.contextFiles.count} file(s), ${prompt.contextFiles.totalChars}c` +
329
+ (fileList.length > 0 ? ` — ${fileList}` : ""),
330
+ );
331
+ lines.push(
332
+ ` skills: ${prompt.skills.visible} visible + ${prompt.skills.hidden} hidden; ` +
333
+ `prompt-section=${prompt.skills.promptSectionChars}c`,
334
+ );
335
+ lines.push(
336
+ ` tools: ${tools.active} active / ${tools.all} registered; schemas=${tools.schemaChars}c; ` +
337
+ `guidelines=${prompt.toolGuidelineChars}c; snippets=${prompt.toolSnippetChars}c`,
338
+ );
339
+ if (tools.bySource.length > 0) {
340
+ const rows = tools.bySource
341
+ .map((r) => `${r.source}=${r.active} (${r.schemaChars}c)`)
342
+ .join("; ");
343
+ lines.push(` per source: ${rows}`);
344
+ }
345
+ lines.push(
346
+ ` branch: ${branch.entries} entries; binding-header-copies=${branch.bindingHeaderCopies}`,
347
+ );
348
+ const perkRows = branch.perkContexts.map(
349
+ (r) => `${r.customType} ×${r.copies} (${r.totalChars}c)`,
350
+ );
351
+ const perkSegment = perkRows.length > 0 ? perkRows.join("; ") : "none";
352
+ lines.push(
353
+ ` perk contexts: ${perkSegment}; other custom_message ` +
354
+ `×${branch.otherCustomMessages.copies} (${branch.otherCustomMessages.totalChars}c)`,
355
+ );
356
+ return lines.join("\n");
357
+ }
358
+
133
359
  /**
134
360
  * Register `/perk-selfcheck`: the session-wiring verifier. Runs on a command context (the only
135
361
  * context exposing `getSystemPromptOptions()`), so it sees the live splice — not just disk.
@@ -149,8 +375,15 @@ export function registerSelfcheck(
149
375
  onDiskIndex: readAmbientIndex(ctx.cwd),
150
376
  options,
151
377
  });
152
- // Headless-safe: report() surfaces the derived booleans/counts (never raw prompt content).
153
- reportTo(ctx, "selfcheck", report.level, report.summary);
378
+ // The census is report-only: it rides the same report but never affects ok/level. A command
379
+ // doesn't fire `before_agent_start`, so the branch reads as of the last completed turn.
380
+ const census = renderCensus(
381
+ promptCensus(options),
382
+ toolsCensus(pi.getAllTools(), pi.getActiveTools()),
383
+ branchContextCensus(branchOf(ctx)),
384
+ );
385
+ // Headless-safe: report() surfaces the derived counts/identifiers (never raw prompt content).
386
+ reportTo(ctx, "selfcheck", report.level, `${report.summary}\n${census}`);
154
387
  },
155
388
  });
156
389
  }
@@ -25,6 +25,7 @@ import { registerPerkCommand } from "../substrate/command.ts";
25
25
  import { loadPerkConfig } from "../substrate/config.ts";
26
26
  import { render } from "../substrate/prompts.ts";
27
27
  import { failFor, type OkDetails, ok, type Result } from "../substrate/result.ts";
28
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
28
29
  import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
29
30
  import { report } from "../surfaces/report.ts";
30
31
 
@@ -119,6 +120,25 @@ export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise
119
120
  });
120
121
  if (!r.ok) return fail(r.message, r.errorType);
121
122
 
123
+ // Capture `implementation/main` at the moment the run id enters `impl_run_ids` (contracts.md
124
+ // §8.35): any run id stamped into the linkage gets its pointer captured in the same gesture.
125
+ // This covers address/warm sessions that submit — which the stage-gated `session_start` capture
126
+ // never sees — so a submitted run resolves `found` instead of `missing`. For the implement
127
+ // session's own /submit it is an idempotent same-session refresh; `preserveForeign` guarantees
128
+ // it can never clobber a different session's pointer. Best-effort + non-fatal like every
129
+ // capture site (a successful submit must stand).
130
+ if (runId) {
131
+ captureSessionPointer({
132
+ cwd: ctx.cwd,
133
+ runId,
134
+ klass: "implementation",
135
+ site: "main",
136
+ // Optional-chained: best-effort, and some side-session fakes have no getSessionFile.
137
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
138
+ preserveForeign: true,
139
+ });
140
+ }
141
+
122
142
  const verb = r.data.pr.existed ? "Found existing" : "Opened draft";
123
143
  const conflicted = r.data.mergeable === false && (r.data.conflicts?.length ?? 0) > 0;
124
144
  // Reset the counter on every clean (or undetermined) submit — idempotent; keeps a later