@mgiles/perk 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/address.ts +11 -0
  3. package/extension/doors/commitCompact.ts +98 -10
  4. package/extension/doors/draftReviewWaveTools.ts +43 -15
  5. package/extension/doors/dreamWaveTools.ts +489 -0
  6. package/extension/doors/land.ts +6 -0
  7. package/extension/doors/learn.ts +16 -3
  8. package/extension/doors/lifecycleGates.ts +36 -1
  9. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  10. package/extension/doors/objectiveStack.ts +424 -24
  11. package/extension/doors/planReviewBrowser.ts +30 -8
  12. package/extension/doors/plannotatorHandoff.ts +80 -8
  13. package/extension/doors/prReview.ts +158 -50
  14. package/extension/doors/prReviewBrowser.ts +75 -27
  15. package/extension/doors/prReviewDynamic.ts +33 -13
  16. package/extension/doors/ready.ts +209 -17
  17. package/extension/doors/reviewWaveTools.ts +61 -17
  18. package/extension/doors/stackReviewBrowser.ts +573 -0
  19. package/extension/doors/submit.ts +36 -10
  20. package/extension/doors/submitPrReview.ts +116 -19
  21. package/extension/factories/objectiveDraft.ts +95 -27
  22. package/extension/factories/objectiveDreamReport.ts +347 -0
  23. package/extension/factories/objectivePlan.ts +12 -6
  24. package/extension/factories/objectiveSave.ts +77 -1
  25. package/extension/factories/planReview.ts +173 -10
  26. package/extension/index.ts +88 -16
  27. package/extension/substrate/agentScratch.ts +171 -0
  28. package/extension/substrate/bindingDelivery.ts +9 -11
  29. package/extension/substrate/cache.ts +92 -2
  30. package/extension/substrate/command.ts +9 -6
  31. package/extension/substrate/config.ts +10 -3
  32. package/extension/substrate/git.ts +85 -2
  33. package/extension/substrate/paths.ts +2 -7
  34. package/extension/substrate/resolverLease.ts +363 -0
  35. package/extension/substrate/result.ts +3 -2
  36. package/extension/substrate/sessionData.ts +6 -4
  37. package/extension/substrate/sessionPointers.ts +3 -4
  38. package/extension/substrate/toolGating.ts +25 -0
  39. package/extension/substrate/workflowState.ts +57 -5
  40. package/extension/surfaces/report.ts +38 -12
  41. package/extension/surfaces/surfaces.ts +129 -7
  42. package/extension/vendor/btw/btw.ts +38 -6
  43. package/extension/waves/adversarialReviewWave.ts +34 -3
  44. package/extension/waves/draftReviewWave.ts +17 -1
  45. package/extension/waves/dreamReducerWave.ts +700 -0
  46. package/extension/waves/dreamReport.ts +1494 -0
  47. package/extension/waves/dreamWave.ts +927 -0
  48. package/extension/waves/harvestWave.ts +1 -1
  49. package/extension/waves/ponytail.ts +104 -0
  50. package/extension/waves/prReviewDynamicWave.ts +115 -34
  51. package/extension/waves/prReviewWave.ts +122 -17
  52. package/extension/waves/reportWave.ts +103 -7
  53. package/extension/worker/readOnlySession.ts +2 -3
  54. package/package.json +6 -3
  55. package/prompts/_fixtures/live.yaml +112 -0
  56. package/prompts/commit-and-compact-continuation.md +13 -0
  57. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  58. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  59. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  60. package/prompts/contexts/plan-authoring.md +6 -5
  61. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  62. package/prompts/stages/conflict-resolution.md +2 -2
  63. package/prompts/stages/learn-dream.md +10 -0
  64. package/prompts/stages/objective-author/adopt.md +1 -1
  65. package/prompts/stages/objective-author/file.md +1 -1
  66. package/prompts/stages/objective-author/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile-ready.md +7 -0
  68. package/prompts/stages/objective-review-browser.md +1 -1
  69. package/prompts/stages/objective-sync.md +1 -1
  70. package/prompts/stages/plan-review-browser.md +1 -1
  71. package/prompts/stages/pr-review-browser/active.md +1 -1
  72. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  73. package/prompts/stages/pr-review-dynamic.md +5 -5
  74. package/prompts/stages/pr-review-terminal/active.md +1 -1
  75. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  76. package/prompts/stages/pr-review-terminal/local.md +1 -1
  77. package/prompts/stages/pr-review.md +5 -5
  78. package/prompts/stages/stack-review/cold.md +1 -0
  79. package/prompts/stages/stack-review-browser/stack.md +23 -0
  80. package/shared/README.md +0 -3
  81. package/shared/bindings.yaml +6 -0
  82. package/shared/contracts.md +3784 -1851
  83. package/shared/registry.yaml +28 -13
  84. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  85. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  86. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  87. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  88. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  89. package/shared/contracts-history.md +0 -605
@@ -91,7 +91,7 @@ function stringOrNull(value: unknown): value is string | null {
91
91
  * The lexical doc-containment layer (pure): relative, POSIX-normalizes without escaping, and
92
92
  * stays under `docs/learned/`. Returns the human-readable violation, or null when contained.
93
93
  */
94
- function lexicalContainmentError(path: string): string | null {
94
+ export function lexicalContainmentError(path: string): string | null {
95
95
  if (posix.isAbsolute(path) || isAbsolute(path)) {
96
96
  return "is absolute";
97
97
  }
@@ -0,0 +1,104 @@
1
+ // The source-identity boundary for Perk's automatic Ponytail review lanes. Ponytail is installed
2
+ // as an all-resource-disabled project package, so ordinary Pi sessions never discover any of its
3
+ // resources. A review lane opts into exactly one skill through its agent's invocation-local
4
+ // `skillPath`; this module preflights the exact project package + file before that lane can spawn.
5
+ // A same-named project/user skill is never a fallback source.
6
+
7
+ import { readFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ import { parse as parseMiniYaml } from "../substrate/miniYaml.ts";
11
+
12
+ export const PONYTAIL_PACKAGE_NAME = "@dietrichgebert/ponytail";
13
+ export const PONYTAIL_PACKAGE_ROOT = ".pi/npm/node_modules/@dietrichgebert/ponytail";
14
+
15
+ export type PonytailSkillName = "ponytail" | "ponytail-review";
16
+
17
+ /** Non-serialized lane metadata: the exact package skill a lane requires. */
18
+ export interface RequiredPonytailSkill {
19
+ skill: PonytailSkillName;
20
+ skillFile: string;
21
+ }
22
+
23
+ export const PONYTAIL_CORE_SKILL: RequiredPonytailSkill = {
24
+ skill: "ponytail",
25
+ skillFile: `${PONYTAIL_PACKAGE_ROOT}/skills/ponytail/SKILL.md`,
26
+ };
27
+
28
+ export const PONYTAIL_REVIEW_SKILL: RequiredPonytailSkill = {
29
+ skill: "ponytail-review",
30
+ skillFile: `${PONYTAIL_PACKAGE_ROOT}/skills/ponytail-review/SKILL.md`,
31
+ };
32
+
33
+ export type PonytailPreflight = { ok: true } | { ok: false; detail: string };
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function frontmatter(text: string): Record<string, unknown> | null {
40
+ if (!text.startsWith("---\n")) return null;
41
+ const lines = text.split("\n");
42
+ const end = lines.findIndex((line, index) => index > 0 && line === "---");
43
+ if (end < 0) return null;
44
+
45
+ // Preflight needs only the one top-level `name` field. Parse that field through Perk's
46
+ // dependency-free YAML seam without widening the contract-file parser to every metadata shape
47
+ // an external skill may carry (notably folded description prose).
48
+ const nameLines = lines.slice(1, end).filter((line) => line.startsWith("name:"));
49
+ if (nameLines.length !== 1) return null;
50
+ try {
51
+ const parsed = parseMiniYaml(nameLines[0] ?? "");
52
+ return isRecord(parsed) ? parsed : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validate one exact source-bound skill against the project install. The check is deliberately
60
+ * self-contained and fail-closed: package identity, `pi.skills`, exact file readability, and
61
+ * frontmatter name must all agree. Callers invoke it once per review pass and skip only the
62
+ * affected lane on failure.
63
+ */
64
+ export async function preflightPonytailSkill(
65
+ requirement: RequiredPonytailSkill,
66
+ repoRoot = process.cwd(),
67
+ ): Promise<PonytailPreflight> {
68
+ const packageRoot = path.resolve(repoRoot, PONYTAIL_PACKAGE_ROOT);
69
+ let manifest: unknown;
70
+ try {
71
+ manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
72
+ } catch {
73
+ return { ok: false, detail: "Ponytail package.json is missing, unreadable, or invalid JSON" };
74
+ }
75
+ if (!isRecord(manifest) || manifest.name !== PONYTAIL_PACKAGE_NAME) {
76
+ return {
77
+ ok: false,
78
+ detail: `Ponytail package identity does not match ${PONYTAIL_PACKAGE_NAME}`,
79
+ };
80
+ }
81
+ const pi = manifest.pi;
82
+ const advertised = isRecord(pi) ? pi.skills : undefined;
83
+ if (!Array.isArray(advertised) || !advertised.includes("./skills")) {
84
+ return { ok: false, detail: "Ponytail package.json does not advertise ./skills in pi.skills" };
85
+ }
86
+
87
+ let skillText: string;
88
+ try {
89
+ skillText = await readFile(path.resolve(repoRoot, requirement.skillFile), "utf8");
90
+ } catch {
91
+ return {
92
+ ok: false,
93
+ detail: `required Ponytail skill is missing or unreadable: ${requirement.skillFile}`,
94
+ };
95
+ }
96
+ const metadata = frontmatter(skillText);
97
+ if (metadata === null || metadata.name !== requirement.skill) {
98
+ return {
99
+ ok: false,
100
+ detail: `required Ponytail skill frontmatter name is not ${requirement.skill}: ${requirement.skillFile}`,
101
+ };
102
+ }
103
+ return { ok: true };
104
+ }
@@ -19,8 +19,8 @@
19
19
  // custom ⇒ the correctness+tests fallback (`source: "fallback"`; a custom-only selection
20
20
  // runs as plan-fidelity + custom — no fallback padding);
21
21
  // - operator-forced angles come first and are always honored; merge order is forced → picks →
22
- // custom, deduped; the additional set caps at 3 (2–4 lanes total incl. plan-fidelity — the
23
- // same window as `/pr-review`), and the custom angle survives only if it fits under the cap
22
+ // custom, deduped; the additional set caps at 3 (2–4 selectable lanes incl. plan-fidelity —
23
+ // the same window as `/pr-review` — plus final Ponytail), and the custom angle survives only if it fits under the cap
24
24
  // (`selection.custom !== null` ⟺ the custom lane launched);
25
25
  // - plan-fidelity is always present, always launched first, never displaced;
26
26
  // - fixed-angle reviewer tasks come ONLY from the render-time-embedded angle→task map — the
@@ -41,12 +41,19 @@
41
41
  // untrusted DATA, never instructions.
42
42
 
43
43
  import {
44
+ PONYTAIL_REVIEW_SKILL,
45
+ type PonytailPreflight,
46
+ preflightPonytailSkill,
47
+ } from "./ponytail.ts";
48
+ import {
49
+ buildPonytailReviewLane,
44
50
  buildPrReviewLanes,
45
51
  directiveSuffix,
46
52
  isPrReviewAngle,
47
53
  PR_REVIEW_ANGLES,
48
54
  PR_REVIEW_REPORT_SCHEMA,
49
55
  type PrReviewAngle,
56
+ reviewTargetSuffix,
50
57
  } from "./prReviewWave.ts";
51
58
  import {
52
59
  normalizeLanes,
@@ -60,6 +67,7 @@ import {
60
67
  type WaveLane,
61
68
  type WaveReport,
62
69
  type WaveScriptReceipt,
70
+ type WaveSpec,
63
71
  } from "./reportWave.ts";
64
72
 
65
73
  /** The additional-angle vocabulary (plan-fidelity is structural — never selectable/removable). */
@@ -146,7 +154,11 @@ const ALL_ANGLES: readonly PrReviewAngle[] = [
146
154
  const CUSTOM_SLUG_PATTERN = /^[a-z][a-z0-9-]{2,31}$/;
147
155
 
148
156
  /** The lane-key namespace a custom slug may not collide with (fixed angles + the selector). */
149
- const RESERVED_LANE_KEYS: readonly string[] = [...Object.keys(PR_REVIEW_ANGLES), "angle-selector"];
157
+ const RESERVED_LANE_KEYS: readonly string[] = [
158
+ ...Object.keys(PR_REVIEW_ANGLES),
159
+ "angle-selector",
160
+ "ponytail",
161
+ ];
150
162
 
151
163
  // The fixed custom-task template parts — ONE source for the exported builder and the
152
164
  // render-time-embedded parts, so in-script custom tasks are byte-identical by construction.
@@ -186,6 +198,8 @@ function isValidCustomSlug(slug: string): boolean {
186
198
  }
187
199
 
188
200
  export interface DynamicReviewScriptOptions {
201
+ /** The resolved active-plan PR number shared by the selector and every reviewer. */
202
+ pr: number;
189
203
  /** The operator's free-form focus, threaded as DATA to the selector AND every reviewer lane. */
190
204
  directive?: string;
191
205
  /** Operator-forced additional angles (embedded as a JSON constant; enforced in normalization). */
@@ -194,6 +208,8 @@ export interface DynamicReviewScriptOptions {
194
208
  reviewerModel?: string;
195
209
  /** The configured `[models.subagents] review-angle-selector` model — the selector item, when set. */
196
210
  selectorModel?: string;
211
+ /** Internal preflight result: false omits only the Ponytail child, never its logical key. */
212
+ includePonytail?: boolean;
197
213
  }
198
214
 
199
215
  /**
@@ -201,7 +217,11 @@ export interface DynamicReviewScriptOptions {
201
217
  * rubric), plus — as DATA — the forced angles when present and the same uniform operator-focus
202
218
  * suffix `buildPrReviewLanes` appends to reviewer lanes.
203
219
  */
204
- function buildSelectorTask(forceAngles: AdditionalPrReviewAngle[], suffix: string): string {
220
+ function buildSelectorTask(
221
+ pr: number,
222
+ forceAngles: AdditionalPrReviewAngle[],
223
+ suffix: string,
224
+ ): string {
205
225
  const forcedNote =
206
226
  forceAngles.length === 0
207
227
  ? ""
@@ -209,9 +229,10 @@ function buildSelectorTask(forceAngles: AdditionalPrReviewAngle[], suffix: strin
209
229
  ", ",
210
230
  )} — they will run regardless of your selection; recommend complementary coverage.`;
211
231
  return (
212
- "Classify the active plan's PR for dynamic review-angle coverage: fetch the review context " +
213
- "yourself (`perk pr review-context --json`), classify the change profile, and select the " +
214
- "review angles per your agent instructions (they own the rubric). Your final action is ONE " +
232
+ `Classify active-plan PR #${pr} for dynamic review-angle coverage: fetch the review context ` +
233
+ `yourself only with \`perk pr review-context --expected-pr ${pr} --json\`, classify the ` +
234
+ "change profile, and select the review angles per your agent instructions (they own the " +
235
+ "rubric). Your final action is ONE " +
215
236
  "structured_output call." +
216
237
  forcedNote +
217
238
  suffix
@@ -232,12 +253,16 @@ function buildSelectorTask(forceAngles: AdditionalPrReviewAngle[], suffix: strin
232
253
  export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): string {
233
254
  // Byte-identical reviewer tasks to the static flow: the map is built by the SAME lane builder
234
255
  // (vocabulary + the uniform directive suffix) over all seven angles.
235
- const lanes = buildPrReviewLanes([...ALL_ANGLES], opts.directive);
236
- const tasks = Object.fromEntries(lanes.map((lane) => [lane.key, lane.task]));
256
+ const lanes = buildPrReviewLanes([...ALL_ANGLES], opts.pr, opts.directive);
257
+ const ponytailLane = buildPonytailReviewLane(opts.pr, opts.directive);
258
+ const tasks = Object.fromEntries([
259
+ ...lanes.map((lane) => [lane.key, lane.task]),
260
+ [ponytailLane.key, ponytailLane.task],
261
+ ]);
237
262
  const suffix = directiveSuffix(opts.directive);
238
263
  const selectorItem = {
239
264
  agent: "perk.review-angle-selector",
240
- task: buildSelectorTask(opts.forceAngles, suffix),
265
+ task: buildSelectorTask(opts.pr, opts.forceAngles, suffix),
241
266
  outputSchema: REVIEW_ANGLE_SELECTOR_SCHEMA,
242
267
  ...(opts.selectorModel !== undefined ? { model: opts.selectorModel } : {}),
243
268
  label: "angle-selector",
@@ -247,11 +272,13 @@ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): str
247
272
  `const TASKS = ${JSON.stringify(tasks, null, 2)};`,
248
273
  `const FORCED = ${JSON.stringify(opts.forceAngles)};`,
249
274
  `const REVIEWER_MODEL = ${JSON.stringify(opts.reviewerModel ?? null)};`,
275
+ `const PONYTAIL_ENABLED = ${JSON.stringify(opts.includePonytail !== false)};`,
250
276
  `const ALLOWLIST_ADDITIONAL = ${JSON.stringify(DYNAMIC_ADDITIONAL_ANGLES)};`,
251
277
  `const FALLBACK_ANGLES = ${JSON.stringify(DYNAMIC_FALLBACK_ANGLES)};`,
252
278
  `const RESERVED = ${JSON.stringify(RESERVED_LANE_KEYS)};`,
253
279
  `const REPORT_SCHEMA = ${JSON.stringify(PR_REVIEW_REPORT_SCHEMA)};`,
254
280
  `const CUSTOM_TASK_PARTS = ${JSON.stringify([CUSTOM_TASK_PREFIX, CUSTOM_TASK_MID])};`,
281
+ `const TARGET_SUFFIX = ${JSON.stringify(reviewTargetSuffix(opts.pr))};`,
255
282
  `const DIRECTIVE_SUFFIX = ${JSON.stringify(suffix)};`,
256
283
  `const CUSTOM_SLUG_RE = new RegExp(${JSON.stringify(CUSTOM_SLUG_PATTERN.source)});`,
257
284
  "const reviewerParams = (angle) => ({",
@@ -259,13 +286,14 @@ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): str
259
286
  " task: TASKS[angle],",
260
287
  " label: angle,",
261
288
  ' phase: "review",',
289
+ ' ...(angle === "ponytail" ? { skill: "ponytail-review" } : {}),',
262
290
  " ...(REVIEWER_MODEL === null ? {} : { model: REVIEWER_MODEL }),",
263
291
  "});",
264
292
  "// The ONE sanctioned custom lane: the validated scope enters through the fixed template",
265
293
  "// (scope-definition-only framing); the per-item schema locks the report's angle echo.",
266
294
  "const customParams = (slug, scope) => ({",
267
295
  ' agent: "perk.pr-reviewer",',
268
- " task: CUSTOM_TASK_PARTS[0] + slug + CUSTOM_TASK_PARTS[1] + scope + DIRECTIVE_SUFFIX,",
296
+ " task: CUSTOM_TASK_PARTS[0] + slug + CUSTOM_TASK_PARTS[1] + scope + TARGET_SUFFIX + DIRECTIVE_SUFFIX,",
269
297
  " label: slug,",
270
298
  ' phase: "review",',
271
299
  " outputSchema: {",
@@ -278,8 +306,11 @@ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): str
278
306
  " (r) => ({ key, ok: r.ok === true, error: r.error ?? null, report: r.structuredOutput ?? null }),",
279
307
  " (error) => ({ key, ok: false, error: error instanceof Error ? error.message : String(error), report: null }),",
280
308
  ");",
281
- "// plan-fidelity launches FIRST and runs concurrently with the selector (held promise).",
309
+ "// Deterministic lanes launch before and independently of selector output (held promises).",
282
310
  'const planFidelity = laneOf("plan-fidelity", runs.run("plan-fidelity", reviewerParams("plan-fidelity")));',
311
+ "const ponytail = PONYTAIL_ENABLED",
312
+ ' ? laneOf("ponytail", runs.run("ponytail", reviewerParams("ponytail")))',
313
+ " : null;",
283
314
  "let sel = null;",
284
315
  "let selectorError = null;",
285
316
  "try {",
@@ -338,11 +369,12 @@ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): str
338
369
  " await planFidelity,",
339
370
  " ...reviewers.map(({ key, ok, error, structuredOutput }) =>",
340
371
  " ({ key, ok, error: error ?? null, report: structuredOutput ?? null })),",
372
+ " ...(ponytail === null ? [] : [await ponytail]),",
341
373
  "];",
342
374
  "return {",
343
375
  " selection: {",
344
376
  " source,",
345
- ' effective: ["plan-fidelity", ...additional],',
377
+ ' effective: ["plan-fidelity", ...additional, "ponytail"],',
346
378
  " forced: FORCED,",
347
379
  " custom,",
348
380
  " selector_ok: report !== null,",
@@ -357,7 +389,7 @@ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): str
357
389
  /** The parent-facing selection metadata (observability for the dogfood — DATA only). */
358
390
  export interface DynamicSelection {
359
391
  source: "selector" | "fallback";
360
- /** The effective lanes: plan-fidelity + ≤3 additional angles, launch order. */
392
+ /** The effective lanes: plan-fidelity + ≤3 additional angles + final Ponytail, launch order. */
361
393
  effective: string[];
362
394
  /** The operator-forced additional angles (echoed from the tool param). */
363
395
  forced: string[];
@@ -375,6 +407,8 @@ export interface DynamicSelection {
375
407
  }
376
408
 
377
409
  export interface PrReviewDynamicOptions {
410
+ /** The resolved active-plan PR number shared by the selector and every reviewer. */
411
+ pr: number;
378
412
  /** The operator's free-form focus, threaded as DATA to the selector and every reviewer lane. */
379
413
  directive?: string;
380
414
  /** Operator-forced additional angles (≤3; plan-fidelity is structural, never forced). */
@@ -385,6 +419,8 @@ export interface PrReviewDynamicOptions {
385
419
  selectorModel?: string;
386
420
  timeoutMs?: number;
387
421
  signal?: AbortSignal;
422
+ /** Test seam; production validates the exact source-bound Ponytail review skill. */
423
+ requiredSkillPreflight?: WaveSpec["requiredSkillPreflight"];
388
424
  }
389
425
 
390
426
  export interface PrReviewDynamicOutcome {
@@ -401,9 +437,9 @@ export interface PrReviewDynamicOutcome {
401
437
  selection: DynamicSelection | null;
402
438
  /**
403
439
  * One output-free receipt per top-level launch, run order (observability only — never a
404
- * decision input). A dynamic launch's `requestedKeys` is the PRE-LAUNCH manifest
405
- * (plan-fidelity + angle-selector) the fan-out lanes appear as receipt children, never
406
- * reconstructed into the manifest.
440
+ * decision input). A dynamic launch's `requestedKeys` is the pre-launch manifest
441
+ * (plan-fidelity + angle-selector + Ponytail); selector-dependent fan-out lanes appear only as
442
+ * receipt children, never reconstructed into that manifest.
407
443
  */
408
444
  attempts: WaveAttemptReceipt[];
409
445
  }
@@ -476,15 +512,18 @@ function parseDynamicValue(
476
512
  }
477
513
  // Re-validate the effective selection against the normalization guarantees.
478
514
  const isEffectiveKey = (slug: string): boolean =>
479
- isPrReviewAngle(slug) || (custom !== null && slug === custom.slug);
515
+ isPrReviewAngle(slug) || slug === "ponytail" || (custom !== null && slug === custom.slug);
480
516
  if (!effective.every(isEffectiveKey)) {
481
517
  return `dynamic selection carries an out-of-allowlist effective angle (${effective.join(", ")})`;
482
518
  }
483
- if (!effective.includes("plan-fidelity")) {
484
- return "dynamic selection dropped the mandatory plan-fidelity lane";
519
+ if (effective[0] !== "plan-fidelity") {
520
+ return "dynamic selection dropped or displaced the mandatory plan-fidelity lane";
521
+ }
522
+ if (effective.at(-1) !== "ponytail") {
523
+ return "dynamic selection dropped or displaced the final Ponytail lane";
485
524
  }
486
- if (effective.length > 4) {
487
- return `dynamic selection exceeds the 4-lane cap (${effective.join(", ")})`;
525
+ if (effective.length > 5) {
526
+ return `dynamic selection exceeds the 5-lane effective cap (${effective.join(", ")})`;
488
527
  }
489
528
  if (new Set(effective).size !== effective.length) {
490
529
  return `dynamic selection carries duplicate effective angles (${effective.join(", ")})`;
@@ -507,7 +546,7 @@ function parseDynamicValue(
507
546
  }
508
547
 
509
548
  /** The pre-launch lane manifest of every dynamic script run (the fan-out is unknowable). */
510
- const DYNAMIC_REQUESTED_KEYS: readonly string[] = ["plan-fidelity", "angle-selector"];
549
+ const DYNAMIC_REQUESTED_KEYS: readonly string[] = ["plan-fidelity", "angle-selector", "ponytail"];
511
550
 
512
551
  /**
513
552
  * Enrich receipt children's `agent` via the module's deterministic mapping (the dynamic
@@ -541,12 +580,15 @@ type DynamicRun =
541
580
  async function runDynamicOnce(
542
581
  adapter: WaveAdapter,
543
582
  opts: PrReviewDynamicOptions,
583
+ ponytailCheck: PonytailPreflight,
544
584
  ): Promise<DynamicRun> {
545
585
  const workflowScript = renderDynamicReviewScript({
586
+ pr: opts.pr,
546
587
  forceAngles: opts.forceAngles ?? [],
547
588
  ...(opts.directive !== undefined ? { directive: opts.directive } : {}),
548
589
  ...(opts.reviewerModel !== undefined ? { reviewerModel: opts.reviewerModel } : {}),
549
590
  ...(opts.selectorModel !== undefined ? { selectorModel: opts.selectorModel } : {}),
591
+ includePonytail: ponytailCheck.ok,
550
592
  });
551
593
  const run = await runWaveScript(
552
594
  adapter,
@@ -571,7 +613,17 @@ async function runDynamicOnce(
571
613
  receipt,
572
614
  };
573
615
  }
574
- const { reports, failures } = normalizeLanes(parsed.selection.effective, parsed.lanes);
616
+ const runnableKeys = parsed.selection.effective.filter(
617
+ (key) => key !== "ponytail" || ponytailCheck.ok,
618
+ );
619
+ const { reports, failures } = normalizeLanes(runnableKeys, parsed.lanes);
620
+ if (!ponytailCheck.ok) {
621
+ failures.push({
622
+ key: "ponytail",
623
+ reason: "skill-unavailable",
624
+ detail: ponytailCheck.detail,
625
+ });
626
+ }
575
627
  return { kind: "parsed", selection: parsed.selection, reports, failures, receipt };
576
628
  }
577
629
 
@@ -609,9 +661,21 @@ function outcomeOf(
609
661
  */
610
662
  export async function runPrReviewDynamicWave(
611
663
  adapter: WaveAdapter,
612
- opts: PrReviewDynamicOptions = {},
664
+ opts: PrReviewDynamicOptions,
613
665
  ): Promise<PrReviewDynamicOutcome> {
614
- const first = await runDynamicOnce(adapter, opts);
666
+ const preflight = opts.requiredSkillPreflight ?? preflightPonytailSkill;
667
+ const ponytailCheck = await preflight(PONYTAIL_REVIEW_SKILL);
668
+ const skillFailures = (): WaveFailure[] =>
669
+ ponytailCheck.ok
670
+ ? []
671
+ : [
672
+ {
673
+ key: "ponytail",
674
+ reason: "skill-unavailable",
675
+ detail: ponytailCheck.detail,
676
+ },
677
+ ];
678
+ const first = await runDynamicOnce(adapter, opts, ponytailCheck);
615
679
  // Ordered attempts — the first attempt's receipt is preserved verbatim when a retry runs.
616
680
  const attempts = [
617
681
  toAttemptReceipt("pr-review-dynamic", 1, [...DYNAMIC_REQUESTED_KEYS], first.receipt),
@@ -619,22 +683,22 @@ export async function runPrReviewDynamicWave(
619
683
 
620
684
  if (first.kind === "wave-failure") {
621
685
  if (!RETRYABLE_WAVE_REASONS.has(first.failure.reason)) {
622
- return outcomeOf(null, [], [first.failure], [], attempts);
686
+ return outcomeOf(null, [], [first.failure, ...skillFailures()], [], attempts);
623
687
  }
624
688
  // Retryable wave-level failure ⇒ ONE full dynamic re-run (fresh selector); its selection
625
689
  // supersedes. The re-run's outcome is final — never a second retry.
626
- const second = await runDynamicOnce(adapter, opts);
690
+ const second = await runDynamicOnce(adapter, opts, ponytailCheck);
627
691
  attempts.push(
628
692
  toAttemptReceipt("pr-review-dynamic", 2, [...DYNAMIC_REQUESTED_KEYS], second.receipt),
629
693
  );
630
694
  if (second.kind === "wave-failure") {
631
- return outcomeOf(null, [], [second.failure], [], attempts);
695
+ return outcomeOf(null, [], [second.failure, ...skillFailures()], [], attempts);
632
696
  }
633
697
  return outcomeOf(
634
698
  second.selection,
635
699
  second.reports,
636
700
  second.failures,
637
- second.selection.effective,
701
+ second.selection.effective.filter((key) => key !== "ponytail" || ponytailCheck.ok),
638
702
  attempts,
639
703
  );
640
704
  }
@@ -647,16 +711,21 @@ export async function runPrReviewDynamicWave(
647
711
  // the custom lane via the fixed template + its per-lane report schema); the selector is never
648
712
  // re-run.
649
713
  const failedKeys = first.selection.effective.filter((key) =>
650
- first.failures.some((failure) => failure.key === key),
714
+ first.failures.some((failure) => failure.key === key && failure.reason !== "skill-unavailable"),
651
715
  );
652
716
  const custom = first.selection.custom;
653
717
  const retryAngles = failedKeys.filter(isPrReviewAngle);
654
718
  const customRetry = custom !== null && failedKeys.includes(custom.slug) ? custom : null;
655
- const retryKeys = [...retryAngles, ...(customRetry !== null ? [customRetry.slug] : [])];
719
+ const ponytailRetry = ponytailCheck.ok && failedKeys.includes("ponytail");
720
+ const retryKeys = [
721
+ ...retryAngles,
722
+ ...(customRetry !== null ? [customRetry.slug] : []),
723
+ ...(ponytailRetry ? ["ponytail"] : []),
724
+ ];
656
725
  if (retryKeys.length === 0) return firstOutcome;
657
726
 
658
727
  const retryLanes: WaveLane[] = [
659
- ...buildPrReviewLanes(retryAngles, opts.directive),
728
+ ...buildPrReviewLanes(retryAngles, opts.pr, opts.directive),
660
729
  ...(customRetry !== null
661
730
  ? [
662
731
  {
@@ -666,11 +735,13 @@ export async function runPrReviewDynamicWave(
666
735
  phase: "review",
667
736
  task:
668
737
  buildCustomLaneTask(customRetry.slug, customRetry.scope) +
738
+ reviewTargetSuffix(opts.pr) +
669
739
  directiveSuffix(opts.directive),
670
740
  outputSchema: customReportSchema(customRetry.slug),
671
741
  },
672
742
  ]
673
743
  : []),
744
+ ...(ponytailRetry ? [buildPonytailReviewLane(opts.pr, opts.directive)] : []),
674
745
  ];
675
746
  const staticRetry = await runReportWave(
676
747
  adapter,
@@ -681,6 +752,7 @@ export async function runPrReviewDynamicWave(
681
752
  completeness: "strict",
682
753
  ...(opts.reviewerModel !== undefined ? { model: opts.reviewerModel } : {}),
683
754
  ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
755
+ requiredSkillPreflight: async () => ponytailCheck,
684
756
  },
685
757
  opts.signal,
686
758
  );
@@ -692,5 +764,14 @@ export async function runPrReviewDynamicWave(
692
764
  ...first.reports.filter((report) => !retriedSet.has(report.key)),
693
765
  ...staticRetry.reports,
694
766
  ];
695
- return outcomeOf(first.selection, merged, staticRetry.failures, retryKeys, attempts);
767
+ const carried = first.failures.filter(
768
+ (failure) => failure.reason === "skill-unavailable" && failure.key === "ponytail",
769
+ );
770
+ return outcomeOf(
771
+ first.selection,
772
+ merged,
773
+ [...staticRetry.failures, ...carried],
774
+ retryKeys,
775
+ attempts,
776
+ );
696
777
  }