pi-prompt-template-model 0.7.3 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.0] - 2026-04-21
6
+
7
+ ### Added
8
+ - Added first-class deterministic prompt-template execution for single prompt templates via `deterministic:` or shorthand `run` / `script` frontmatter. Templates can run one direct command or script before any optional LLM turn.
9
+ - Added configurable deterministic-step handoff policies: `always`, `never`, `on-success`, and `on-failure`.
10
+ - Added deterministic result cards that always show the executed command or script, resolved `cwd`, exit code, duration, and stdout/stderr previews.
11
+ - Added deterministic-step `env` support plus `nonInteractive` control for deploy/release-style scripts that need explicit environment variables or want to disable the default non-interactive guardrail env bundle.
12
+ - Added a visible deterministic completion message for `handoff: never`, so no-handoff runs still end with an explicit completion marker after the result card.
13
+
14
+ ### Changed
15
+ - When a deterministic prompt hands off to the model, the extension now prepends a generated `[Deterministic step]` block with structured execution metadata and truncated stdout/stderr previews before the prompt body.
16
+ - Deterministic stdout/stderr payloads are now capped before they are stored in message details, while preserving total line/character counts and truncation metadata for both the card UI and the LLM handoff block.
17
+
18
+ ### Fixed
19
+ - Added regression coverage for deterministic loader parsing, relative script resolution, handoff gating, and the no-handoff fast path.
20
+ - Deterministic timeouts now escalate from `SIGTERM` to `SIGKILL` if the child process does not exit within the post-timeout grace window.
21
+
5
22
  ## [0.7.3] - 2026-04-14
6
23
 
7
24
  ### Fixed
package/README.md CHANGED
@@ -353,6 +353,128 @@ Within a compare lineup, use `task` for a full per-slot override and `taskSuffix
353
353
 
354
354
  When a compare prompt uses `bestOfN.worktree: true`, all worker slots must resolve to the same `cwd`. Mixed worker `cwd` values are only allowed when worktree isolation is off. Worktree isolation is for the worker phase only; `bestOfN.finalApplier` always applies on the real branch (`compareCwd`).
355
355
 
356
+ ## Deterministic Steps
357
+
358
+ Prompt templates can run one deterministic command or script before any optional LLM turn. Use this when the first step should be direct code, not model latency.
359
+
360
+ The flow is simple:
361
+
362
+ 1. Run one command or script.
363
+ 2. Always render a visible deterministic result card with the command, exit code, duration, and stdout/stderr previews.
364
+ 3. Optionally hand the structured result to the model as a `[Deterministic step]` preamble before the prompt body.
365
+ 4. If `handoff: never`, stop after the result card and a visible completion marker — no LLM turn happens.
366
+
367
+ That handoff preamble is intentionally structured and uses stable field names like `status`, `executionKind`, `command`, `cwd`, `exitCode`, `signal`, `durationMs`, `timedOut`, `lineCount`, `charCount`, `truncated`, `omittedChars`, and `preview`.
368
+
369
+ V1 scope is intentionally narrow: deterministic execution only works on single prompt templates. It does not combine with chain templates, delegated/subagent prompts, `parallel`, or loops. At runtime, deterministic prompts explicitly reject `--loop`, `--subagent`, and `--fork` in v1.
370
+
371
+ ### Authoring forms
372
+
373
+ You can write deterministic steps as **top-level shorthand** or **nested under `deterministic:`**. Both are equivalent. Use shorthand for brevity, nested when you want everything grouped under one key.
374
+
375
+ **Top-level shorthand** — put `run`, `script`, `handoff`, `timeout`, `cwd`, `env`, and `nonInteractive` directly in frontmatter:
376
+
377
+ ```markdown
378
+ ---
379
+ run: git push origin HEAD:main
380
+ handoff: on-failure
381
+ timeout: 30000
382
+ ---
383
+ If the push failed, explain why and suggest the next step.
384
+ ```
385
+
386
+ You can also use `script:` as shorthand:
387
+
388
+ ```markdown
389
+ ---
390
+ script: ./scripts/ship.sh
391
+ handoff: always
392
+ timeout: 15000
393
+ ---
394
+ Summarize the script result.
395
+ ```
396
+
397
+ **Nested form** — group everything under `deterministic:`:
398
+
399
+ ```markdown
400
+ ---
401
+ model: claude-sonnet-4-20250514
402
+ deterministic:
403
+ script:
404
+ path: ./scripts/ship.sh
405
+ args:
406
+ - --fast
407
+ handoff: always
408
+ timeout: 15000
409
+ cwd: ~/src/my-repo
410
+ ---
411
+ Summarize the script result and call out anything risky.
412
+ ```
413
+
414
+ **Structured command form** — when you need explicit args instead of a single shell string, use `deterministic.run.command` with `args`:
415
+
416
+ ```markdown
417
+ ---
418
+ model: claude-sonnet-4-20250514
419
+ deterministic:
420
+ run:
421
+ command: git
422
+ args: [status, --short]
423
+ handoff: always
424
+ ---
425
+ Interpret the repo state.
426
+ ```
427
+
428
+ Do not mix top-level shorthand with nested `deterministic:` in the same prompt. Pick one style.
429
+
430
+ ### Model requirement
431
+
432
+ Deterministic prompts that hand off to the model (`handoff: always`, `on-success`, or `on-failure`) need a model to continue into. You can either:
433
+
434
+ - Add a `model:` field explicitly
435
+ - Omit `model:` and let the prompt inherit whatever model is currently active
436
+
437
+ `handoff: never` prompts do not need a model field because they never reach the LLM.
438
+
439
+ ### Handoff values
440
+
441
+ - `always` — always continue into the LLM after the deterministic card is emitted.
442
+ - `never` — stop after the deterministic card and completion marker.
443
+ - `on-success` — continue only when the command exits `0`.
444
+ - `on-failure` — continue only when the command exits non-zero.
445
+
446
+ Command descriptions in the slash-command picker show this feature as `deterministic-step:<handoff>`.
447
+
448
+ ### Timeout
449
+
450
+ `timeout` is in milliseconds. When a timeout fires, the runner sends `SIGTERM` first. If the process still has not exited after a short grace window, it escalates to `SIGKILL`.
451
+
452
+ ### Script path resolution
453
+
454
+ Relative script paths resolve from the prompt file's directory first, then fall back to the command invocation `cwd`. Absolute script paths also work.
455
+
456
+ ### Environment and non-interactive mode
457
+
458
+ You can provide explicit environment variables and control the runner's non-interactive guardrails:
459
+
460
+ ```markdown
461
+ ---
462
+ deterministic:
463
+ run: ./deploy.sh
464
+ handoff: never
465
+ nonInteractive: false
466
+ env:
467
+ SPECIAL_TOKEN: abc123
468
+ RETRIES: 2
469
+ ---
470
+ ```
471
+
472
+ `nonInteractive` defaults to `true`. In that mode the runner keeps stdin ignored and adds a few guardrail environment defaults such as `CI=1`, `GIT_TERMINAL_PROMPT=0`, `PAGER=cat`, and `GIT_PAGER=cat`. Set `nonInteractive: false` when the command needs a more normal process environment and you explicitly want to opt out of those defaults. Explicit `env` values override the built-in defaults.
473
+
474
+ ### Output capping
475
+
476
+ Large stdout/stderr streams are capped before they are stored in the conversation card payload. The card and the LLM handoff block both show the total character and line counts plus explicit truncation metadata when output was capped.
477
+
356
478
  ## Loop Execution
357
479
 
358
480
  Run a template multiple times with `--loop`:
@@ -672,3 +794,4 @@ $@
672
794
  - In chains, model-less steps inherit the chain-start model snapshot, not the previous step's model. This is intentional for deterministic behavior.
673
795
  - Delegated `subagent` prompts require [pi-subagents](https://github.com/nicobailon/pi-subagents/).
674
796
  - `run-prompt` must be explicitly enabled with `/prompt-tool on`.
797
+ rompt-tool on`.
@@ -0,0 +1,136 @@
1
+ import type { MessageRenderOptions, Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Box, Container, Spacer, Text } from "@mariozechner/pi-tui";
3
+ import { formatDeterministicExecution, type DeterministicExecutionResult } from "./deterministic-step.js";
4
+
5
+ interface DeterministicMessage {
6
+ content?: unknown;
7
+ details?: DeterministicExecutionResult;
8
+ }
9
+
10
+ interface DeterministicCompletionMessage {
11
+ content?: unknown;
12
+ details?: {
13
+ promptName: string;
14
+ exitCode: number;
15
+ timedOut: boolean;
16
+ status: "succeeded" | "failed";
17
+ };
18
+ }
19
+
20
+ const PREVIEW_LINES = 8;
21
+
22
+ function formatDuration(durationMs: number): string {
23
+ if (durationMs < 1_000) return `${durationMs}ms`;
24
+ if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
25
+ return `${Math.round(durationMs / 1_000)}s`;
26
+ }
27
+
28
+ function buildCapturedOutputLabel(
29
+ label: string,
30
+ meta: { totalChars: number; totalLines: number; truncated: boolean },
31
+ ): string {
32
+ if (meta.totalChars === 0) return `${label} · empty`;
33
+ const lineCount = meta.totalLines;
34
+ const charCount = meta.totalChars.toLocaleString();
35
+ const truncated = meta.truncated ? " · capped" : "";
36
+ return `${label} · ${lineCount} line${lineCount === 1 ? "" : "s"} · ${charCount} chars${truncated}`;
37
+ }
38
+
39
+ function renderOutputSection(
40
+ box: Box,
41
+ label: string,
42
+ value: string,
43
+ meta: { totalChars: number; totalLines: number; truncated: boolean },
44
+ options: MessageRenderOptions,
45
+ theme: Theme,
46
+ ) {
47
+ box.addChild(new Text(theme.fg("toolTitle", buildCapturedOutputLabel(label, meta)), 0, 0));
48
+ if (!value) {
49
+ box.addChild(new Text(theme.fg("dim", "(empty)"), 0, 0));
50
+ return;
51
+ }
52
+ const lines = value.split("\n");
53
+ if (options.expanded || lines.length <= PREVIEW_LINES) {
54
+ box.addChild(new Text(theme.fg("toolOutput", value), 0, 0));
55
+ if (meta.truncated) {
56
+ box.addChild(new Text(theme.fg("warning", `\n... (stored preview capped, ${Math.max(0, meta.totalChars - value.length)} more chars hidden)`), 0, 0));
57
+ }
58
+ return;
59
+ }
60
+ box.addChild(new Text(theme.fg("toolOutput", lines.slice(0, PREVIEW_LINES).join("\n")), 0, 0));
61
+ box.addChild(new Text(theme.fg("warning", `\n... (${lines.length - PREVIEW_LINES} more lines hidden — Ctrl+O to expand)`), 0, 0));
62
+ if (meta.truncated) {
63
+ box.addChild(new Text(theme.fg("warning", `\n... (stored preview capped, ${Math.max(0, meta.totalChars - value.length)} more chars hidden)`), 0, 0));
64
+ }
65
+ }
66
+
67
+ export function renderDeterministicResult(message: DeterministicMessage, options: MessageRenderOptions, theme: Theme) {
68
+ const details = message.details;
69
+ const container = new Container();
70
+ container.addChild(new Spacer(1));
71
+ if (!details) {
72
+ container.addChild(new Text(theme.fg("warning", "Deterministic step message is missing details."), 0, 0));
73
+ return container;
74
+ }
75
+
76
+ const failed = details.exitCode !== 0;
77
+ const box = new Box(1, 1, (text: string) => theme.bg(failed ? "toolPendingBg" : "toolSuccessBg", text));
78
+ const icon = theme.fg(failed ? "error" : "success", failed ? "fail" : "ok");
79
+ const status = failed ? "failed" : "succeeded";
80
+ const title = formatDeterministicExecution(details.execution, details.resolvedScriptPath);
81
+ box.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold("deterministic"))} | ${status} · exit ${details.exitCode} · ${formatDuration(details.durationMs)}`, 0, 0));
82
+ box.addChild(new Spacer(1));
83
+ box.addChild(new Text(theme.fg("dim", `command: ${title}`), 0, 0));
84
+ if (details.resolvedScriptPath) {
85
+ box.addChild(new Text(theme.fg("dim", `script: ${details.resolvedScriptPath}`), 0, 0));
86
+ }
87
+ box.addChild(new Text(theme.fg("dim", `cwd: ${details.cwd}`), 0, 0));
88
+ if (details.signal) {
89
+ box.addChild(new Text(theme.fg("dim", `signal: ${details.signal}`), 0, 0));
90
+ }
91
+ if (details.timedOut) {
92
+ box.addChild(new Text(theme.fg("error", "timeout reached before the process exited"), 0, 0));
93
+ }
94
+ box.addChild(new Text(theme.fg("dim", `nonInteractive: ${details.nonInteractive ? "true" : "false"}`), 0, 0));
95
+ box.addChild(new Spacer(1));
96
+ renderOutputSection(box, "stdout", details.stdout, {
97
+ totalChars: details.stdoutTotalChars,
98
+ totalLines: details.stdoutTotalLines,
99
+ truncated: details.stdoutTruncated,
100
+ }, options, theme);
101
+ box.addChild(new Spacer(1));
102
+ renderOutputSection(box, "stderr", details.stderr, {
103
+ totalChars: details.stderrTotalChars,
104
+ totalLines: details.stderrTotalLines,
105
+ truncated: details.stderrTruncated,
106
+ }, options, theme);
107
+ container.addChild(box);
108
+ return container;
109
+ }
110
+
111
+ export function renderDeterministicCompletion(
112
+ message: DeterministicCompletionMessage,
113
+ _options: MessageRenderOptions,
114
+ theme: Theme,
115
+ ) {
116
+ const details = message.details;
117
+ const container = new Container();
118
+ container.addChild(new Spacer(1));
119
+ if (!details) {
120
+ container.addChild(new Text(theme.fg("warning", "Deterministic completion message is missing details."), 0, 0));
121
+ return container;
122
+ }
123
+
124
+ const failed = details.status === "failed";
125
+ const box = new Box(1, 1, (text: string) => theme.bg(failed ? "toolPendingBg" : "toolSuccessBg", text));
126
+ const icon = theme.fg(failed ? "error" : "success", failed ? "fail" : "ok");
127
+ box.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold("deterministic complete"))} | ${details.status} · exit ${details.exitCode}`, 0, 0));
128
+ box.addChild(new Spacer(1));
129
+ box.addChild(new Text(theme.fg("dim", `prompt: ${details.promptName}`), 0, 0));
130
+ box.addChild(new Text(theme.fg("dim", "model handoff: skipped"), 0, 0));
131
+ if (details.timedOut) {
132
+ box.addChild(new Text(theme.fg("error", "the command hit its timeout before completion"), 0, 0));
133
+ }
134
+ container.addChild(box);
135
+ return container;
136
+ }
@@ -0,0 +1,309 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import type { PromptWithModel, DeterministicStep, DeterministicExecution, DeterministicEnv } from "./prompt-loader.js";
5
+
6
+ export const PROMPT_TEMPLATE_DETERMINISTIC_MESSAGE_TYPE = "prompt-template-deterministic";
7
+ export const PROMPT_TEMPLATE_DETERMINISTIC_COMPLETION_MESSAGE_TYPE = "prompt-template-deterministic-complete";
8
+
9
+ const DEFAULT_MAX_CAPTURE_STDOUT_CHARS = 16_000;
10
+ const DEFAULT_MAX_CAPTURE_STDERR_CHARS = 16_000;
11
+ const DEFAULT_TIMEOUT_KILL_AFTER_MS = 1_000;
12
+
13
+ interface CapturedOutput {
14
+ text: string;
15
+ totalChars: number;
16
+ totalNewlines: number;
17
+ trailingNewlineRun: number;
18
+ sawNonNewline: boolean;
19
+ truncated: boolean;
20
+ maxChars: number;
21
+ }
22
+
23
+ export interface DeterministicExecutionResult {
24
+ execution: DeterministicExecution;
25
+ cwd: string;
26
+ nonInteractive: boolean;
27
+ resolvedScriptPath?: string;
28
+ exitCode: number;
29
+ signal?: NodeJS.Signals;
30
+ stdout: string;
31
+ stdoutTotalChars: number;
32
+ stdoutTotalLines: number;
33
+ stdoutTruncated: boolean;
34
+ stderr: string;
35
+ stderrTotalChars: number;
36
+ stderrTotalLines: number;
37
+ stderrTruncated: boolean;
38
+ durationMs: number;
39
+ timedOut: boolean;
40
+ }
41
+
42
+ export interface DeterministicPreambleOptions {
43
+ maxStdoutChars?: number;
44
+ maxStderrChars?: number;
45
+ }
46
+
47
+ function createCapturedOutput(maxChars: number): CapturedOutput {
48
+ return {
49
+ text: "",
50
+ totalChars: 0,
51
+ totalNewlines: 0,
52
+ trailingNewlineRun: 0,
53
+ sawNonNewline: false,
54
+ truncated: false,
55
+ maxChars,
56
+ };
57
+ }
58
+
59
+ function appendCapturedOutput(output: CapturedOutput, chunk: string): void {
60
+ if (!chunk) return;
61
+ output.totalChars += chunk.length;
62
+ const newlines = chunk.match(/\n/g)?.length ?? 0;
63
+ output.totalNewlines += newlines;
64
+ if (/[^\n]/.test(chunk)) output.sawNonNewline = true;
65
+ const trailingRun = chunk.match(/\n+$/)?.[0].length ?? 0;
66
+ if (trailingRun === 0) {
67
+ output.trailingNewlineRun = 0;
68
+ } else if (trailingRun === chunk.length) {
69
+ output.trailingNewlineRun += trailingRun;
70
+ } else {
71
+ output.trailingNewlineRun = trailingRun;
72
+ }
73
+
74
+ if (output.text.length < output.maxChars) {
75
+ const remaining = output.maxChars - output.text.length;
76
+ output.text += chunk.slice(0, remaining);
77
+ }
78
+ if (output.totalChars > output.maxChars) output.truncated = true;
79
+ }
80
+
81
+ function capturedLineCount(output: Pick<CapturedOutput, "totalChars" | "sawNonNewline" | "totalNewlines" | "trailingNewlineRun">): number {
82
+ if (output.totalChars === 0) return 0;
83
+ if (!output.sawNonNewline) return 1;
84
+ return output.totalNewlines - output.trailingNewlineRun + 1;
85
+ }
86
+
87
+ function countLines(value: string): number {
88
+ if (!value) return 0;
89
+ const normalized = value.replace(/\n+$/g, "");
90
+ if (!normalized) return 1;
91
+ return normalized.split("\n").length;
92
+ }
93
+
94
+ function buildTextPreview(label: string, value: string, totalChars: number, maxChars: number): { text: string; truncated: boolean; omittedChars: number } {
95
+ const shownChars = Math.min(value.length, maxChars);
96
+ const preview = value.slice(0, shownChars);
97
+ const omittedChars = Math.max(0, totalChars - shownChars);
98
+ if (omittedChars === 0) {
99
+ return { text: preview, truncated: false, omittedChars: 0 };
100
+ }
101
+ return {
102
+ text: `${preview}\n...[${label} truncated, ${omittedChars} more chars omitted]`,
103
+ truncated: true,
104
+ omittedChars,
105
+ };
106
+ }
107
+
108
+ function shellQuote(value: string): string {
109
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
110
+ }
111
+
112
+ export function formatDeterministicExecution(execution: DeterministicExecution, resolvedScriptPath?: string): string {
113
+ switch (execution.kind) {
114
+ case "run":
115
+ return execution.command;
116
+ case "command": {
117
+ const parts = [execution.command, ...execution.args].map((part) => shellQuote(part));
118
+ return execution.shell ? `${parts.join(" ")} (shell)` : parts.join(" ");
119
+ }
120
+ case "script": {
121
+ const scriptPath = resolvedScriptPath ?? execution.path;
122
+ const parts = [scriptPath, ...execution.args].map((part) => shellQuote(part));
123
+ return parts.join(" ");
124
+ }
125
+ }
126
+ }
127
+
128
+ export function shouldHandoffToLlm(step: DeterministicStep, result: Pick<DeterministicExecutionResult, "exitCode">): boolean {
129
+ switch (step.handoff) {
130
+ case "always": return true;
131
+ case "never": return false;
132
+ case "on-success": return result.exitCode === 0;
133
+ case "on-failure": return result.exitCode !== 0;
134
+ }
135
+ }
136
+
137
+ function buildOutputPreambleSectionFromResult(
138
+ label: "stdout" | "stderr",
139
+ value: string,
140
+ meta: { totalChars: number; totalLines: number },
141
+ maxChars: number,
142
+ ): string[] {
143
+ const preview = buildTextPreview(label, value, meta.totalChars, maxChars);
144
+ return [
145
+ `[${label}]`,
146
+ `lineCount: ${meta.totalLines}`,
147
+ `charCount: ${meta.totalChars}`,
148
+ `truncated: ${preview.truncated ? "true" : "false"}`,
149
+ preview.truncated ? `omittedChars: ${preview.omittedChars}` : undefined,
150
+ "preview:",
151
+ preview.text || "(empty)",
152
+ ];
153
+ }
154
+
155
+ export function buildDeterministicPreamble(
156
+ result: DeterministicExecutionResult,
157
+ options: DeterministicPreambleOptions = {},
158
+ ): string {
159
+ const maxStdoutChars = options.maxStdoutChars ?? 8_000;
160
+ const maxStderrChars = options.maxStderrChars ?? 4_000;
161
+ const command = formatDeterministicExecution(result.execution, result.resolvedScriptPath);
162
+ return [
163
+ "[Deterministic step]",
164
+ `status: ${result.exitCode === 0 ? "succeeded" : "failed"}`,
165
+ `executionKind: ${result.execution.kind}`,
166
+ `command: ${command.includes("\n") ? JSON.stringify(command) : command}`,
167
+ result.resolvedScriptPath ? `resolvedScript: ${result.resolvedScriptPath}` : undefined,
168
+ `cwd: ${result.cwd}`,
169
+ `nonInteractive: ${result.nonInteractive ? "true" : "false"}`,
170
+ `exitCode: ${result.exitCode}`,
171
+ result.signal ? `signal: ${result.signal}` : undefined,
172
+ `durationMs: ${result.durationMs}`,
173
+ `timedOut: ${result.timedOut ? "true" : "false"}`,
174
+ "",
175
+ ...buildOutputPreambleSectionFromResult("stdout", result.stdout, {
176
+ totalChars: result.stdoutTotalChars,
177
+ totalLines: result.stdoutTotalLines,
178
+ }, maxStdoutChars),
179
+ "",
180
+ ...buildOutputPreambleSectionFromResult("stderr", result.stderr, {
181
+ totalChars: result.stderrTotalChars,
182
+ totalLines: result.stderrTotalLines,
183
+ }, maxStderrChars),
184
+ ].filter((line): line is string => line !== undefined).join("\n");
185
+ }
186
+
187
+ function resolveScriptPath(prompt: Pick<PromptWithModel, "filePath">, cwd: string, execution: Extract<DeterministicExecution, { kind: "script" }>): string {
188
+ if (isAbsolute(execution.path)) return execution.path;
189
+ const promptRelative = resolve(dirname(prompt.filePath), execution.path);
190
+ if (existsSync(promptRelative)) return promptRelative;
191
+ return resolve(cwd, execution.path);
192
+ }
193
+
194
+ function buildDeterministicEnv(step: Pick<DeterministicStep, "env" | "nonInteractive">): NodeJS.ProcessEnv {
195
+ const nonInteractiveDefaults: DeterministicEnv = step.nonInteractive
196
+ ? {
197
+ CI: "1",
198
+ GIT_TERMINAL_PROMPT: "0",
199
+ PAGER: "cat",
200
+ GIT_PAGER: "cat",
201
+ }
202
+ : {};
203
+ return {
204
+ ...process.env,
205
+ ...nonInteractiveDefaults,
206
+ ...(step.env ?? {}),
207
+ };
208
+ }
209
+
210
+ function spawnProcess(command: string, args: string[], options: { cwd: string; shell?: boolean; env: NodeJS.ProcessEnv }) {
211
+ return spawn(command, args, {
212
+ cwd: options.cwd,
213
+ shell: options.shell ?? false,
214
+ env: options.env,
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ });
217
+ }
218
+
219
+ export async function runDeterministicStep(
220
+ prompt: Pick<PromptWithModel, "filePath">,
221
+ step: DeterministicStep,
222
+ cwd: string,
223
+ ): Promise<DeterministicExecutionResult> {
224
+ const startedAt = Date.now();
225
+ const execution = step.execution;
226
+ const resolvedCwd = step.cwd ?? cwd;
227
+ const env = buildDeterministicEnv(step);
228
+ const resolvedScriptPath = execution.kind === "script"
229
+ ? resolveScriptPath(prompt, resolvedCwd, execution)
230
+ : undefined;
231
+ const child = execution.kind === "run"
232
+ ? spawnProcess("/bin/bash", ["-lc", execution.command], { cwd: resolvedCwd, env })
233
+ : execution.kind === "command"
234
+ ? spawnProcess(execution.command, execution.args, { cwd: resolvedCwd, shell: execution.shell, env })
235
+ : spawnProcess(resolvedScriptPath!, execution.args, { cwd: resolvedCwd, env });
236
+
237
+ const stdout = createCapturedOutput(DEFAULT_MAX_CAPTURE_STDOUT_CHARS);
238
+ const stderr = createCapturedOutput(DEFAULT_MAX_CAPTURE_STDERR_CHARS);
239
+ let timedOut = false;
240
+ let timeoutKillHandle: NodeJS.Timeout | undefined;
241
+
242
+ child.stdout.on("data", (chunk) => {
243
+ appendCapturedOutput(stdout, chunk.toString());
244
+ });
245
+ child.stderr.on("data", (chunk) => {
246
+ appendCapturedOutput(stderr, chunk.toString());
247
+ });
248
+
249
+ const timeoutHandle = step.timeoutMs
250
+ ? setTimeout(() => {
251
+ timedOut = true;
252
+ child.kill("SIGTERM");
253
+ timeoutKillHandle = setTimeout(() => {
254
+ child.kill("SIGKILL");
255
+ }, DEFAULT_TIMEOUT_KILL_AFTER_MS);
256
+ }, step.timeoutMs)
257
+ : undefined;
258
+
259
+ return await new Promise((resolveResult) => {
260
+ let settled = false;
261
+ child.on("error", (error) => {
262
+ if (settled) return;
263
+ settled = true;
264
+ if (timeoutHandle) clearTimeout(timeoutHandle);
265
+ if (timeoutKillHandle) clearTimeout(timeoutKillHandle);
266
+ resolveResult({
267
+ execution,
268
+ cwd: resolvedCwd,
269
+ nonInteractive: step.nonInteractive,
270
+ resolvedScriptPath,
271
+ exitCode: 1,
272
+ stdout: stdout.text,
273
+ stdoutTotalChars: stdout.totalChars,
274
+ stdoutTotalLines: capturedLineCount(stdout),
275
+ stdoutTruncated: stdout.truncated,
276
+ stderr: stderr.text ? `${stderr.text}\n${error.message}` : error.message,
277
+ stderrTotalChars: stderr.totalChars + (stderr.text ? error.message.length + 1 : error.message.length),
278
+ stderrTotalLines: countLines(stderr.text ? `${stderr.text}\n${error.message}` : error.message),
279
+ stderrTruncated: stderr.truncated,
280
+ durationMs: Date.now() - startedAt,
281
+ timedOut,
282
+ });
283
+ });
284
+ child.on("close", (exitCode, signal) => {
285
+ if (settled) return;
286
+ settled = true;
287
+ if (timeoutHandle) clearTimeout(timeoutHandle);
288
+ if (timeoutKillHandle) clearTimeout(timeoutKillHandle);
289
+ resolveResult({
290
+ execution,
291
+ cwd: resolvedCwd,
292
+ nonInteractive: step.nonInteractive,
293
+ resolvedScriptPath,
294
+ exitCode: exitCode ?? (timedOut ? 124 : 1),
295
+ signal: signal ?? undefined,
296
+ stdout: stdout.text,
297
+ stdoutTotalChars: stdout.totalChars,
298
+ stdoutTotalLines: capturedLineCount(stdout),
299
+ stdoutTruncated: stdout.truncated,
300
+ stderr: stderr.text,
301
+ stderrTotalChars: stderr.totalChars,
302
+ stderrTotalLines: capturedLineCount(stderr),
303
+ stderrTruncated: stderr.truncated,
304
+ durationMs: Date.now() - startedAt,
305
+ timedOut,
306
+ });
307
+ });
308
+ });
309
+ }
package/index.ts CHANGED
@@ -34,6 +34,14 @@ import { createToolManager } from "./tool-manager.js";
34
34
  import { executeSubagentPromptStep, type DelegatedPromptParallelResult } from "./subagent-step.js";
35
35
  import { DEFAULT_SUBAGENT_NAME, PROMPT_TEMPLATE_SUBAGENT_MESSAGE_TYPE } from "./subagent-runtime.js";
36
36
  import { renderDelegatedSubagentResult } from "./subagent-renderer.js";
37
+ import {
38
+ PROMPT_TEMPLATE_DETERMINISTIC_COMPLETION_MESSAGE_TYPE,
39
+ PROMPT_TEMPLATE_DETERMINISTIC_MESSAGE_TYPE,
40
+ buildDeterministicPreamble,
41
+ runDeterministicStep,
42
+ shouldHandoffToLlm,
43
+ } from "./deterministic-step.js";
44
+ import { renderDeterministicCompletion, renderDeterministicResult } from "./deterministic-renderer.js";
37
45
 
38
46
  interface LoopState {
39
47
  currentIteration: number;
@@ -125,6 +133,8 @@ export default function promptModelExtension(pi: ExtensionAPI) {
125
133
 
126
134
  pi.registerMessageRenderer<SkillLoadedDetails>("skill-loaded", renderSkillLoaded);
127
135
  pi.registerMessageRenderer(PROMPT_TEMPLATE_SUBAGENT_MESSAGE_TYPE, renderDelegatedSubagentResult);
136
+ pi.registerMessageRenderer(PROMPT_TEMPLATE_DETERMINISTIC_MESSAGE_TYPE, renderDeterministicResult);
137
+ pi.registerMessageRenderer(PROMPT_TEMPLATE_DETERMINISTIC_COMPLETION_MESSAGE_TYPE, renderDeterministicCompletion);
128
138
 
129
139
  function registerPromptCommand(name: string) {
130
140
  pi.registerCommand(name, {
@@ -248,6 +258,40 @@ export default function promptModelExtension(pi: ExtensionAPI) {
248
258
  taskPreamble?: string,
249
259
  loopContext?: string,
250
260
  ): Promise<PromptStepResult | "aborted"> {
261
+ let deterministicPreamble: string | undefined;
262
+ if (prompt.deterministic) {
263
+ try {
264
+ const deterministicResult = await runDeterministicStep(prompt, prompt.deterministic, ctx.cwd);
265
+ const deterministicPreambleText = buildDeterministicPreamble(deterministicResult);
266
+ pi.sendMessage({
267
+ customType: PROMPT_TEMPLATE_DETERMINISTIC_MESSAGE_TYPE,
268
+ content: deterministicPreambleText,
269
+ display: true,
270
+ details: deterministicResult,
271
+ });
272
+ if (!shouldHandoffToLlm(prompt.deterministic, deterministicResult)) {
273
+ pi.sendMessage({
274
+ customType: PROMPT_TEMPLATE_DETERMINISTIC_COMPLETION_MESSAGE_TYPE,
275
+ content: `[Deterministic complete: ${prompt.name}]`,
276
+ display: true,
277
+ details: {
278
+ promptName: prompt.name,
279
+ exitCode: deterministicResult.exitCode,
280
+ timedOut: deterministicResult.timedOut,
281
+ status: deterministicResult.exitCode === 0 ? "succeeded" : "failed",
282
+ },
283
+ });
284
+ return { changed: false };
285
+ }
286
+ deterministicPreamble = deterministicPreambleText;
287
+ } catch (error) {
288
+ notify(ctx, `Deterministic step failed: ${error instanceof Error ? error.message : String(error)}`, "error");
289
+ return "aborted";
290
+ }
291
+ }
292
+
293
+ const combinedTaskPreamble = [taskPreamble, deterministicPreamble].filter(Boolean).join("\n\n");
294
+
251
295
  if (shouldDelegatePrompt(prompt, override)) {
252
296
  try {
253
297
  const delegated =
@@ -259,7 +303,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
259
303
  override,
260
304
  signal: ctx.signal,
261
305
  inheritedModel,
262
- taskPreamble,
306
+ taskPreamble: combinedTaskPreamble || undefined,
263
307
  parallel: Array.from({ length: prompt.parallel }, (_, index) => ({
264
308
  prompt,
265
309
  args,
@@ -276,7 +320,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
276
320
  override,
277
321
  signal: ctx.signal,
278
322
  inheritedModel,
279
- taskPreamble,
323
+ taskPreamble: combinedTaskPreamble || undefined,
280
324
  });
281
325
  if (!delegated) {
282
326
  notify(ctx, `Prompt \`${prompt.name}\` is not configured for delegated execution.`, "error");
@@ -327,7 +371,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
327
371
  pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
328
372
 
329
373
  const startId = ctx.sessionManager.getLeafId();
330
- const content = loopContext ? `[${loopContext}]\n\n${prepared.content}` : prepared.content;
374
+ const effectiveContent = combinedTaskPreamble
375
+ ? `${combinedTaskPreamble}\n\n${prepared.content}`
376
+ : prepared.content;
377
+ const content = loopContext ? `[${loopContext}]\n\n${effectiveContent}` : effectiveContent;
331
378
  pi.sendUserMessage(content);
332
379
  await waitForTurnStart(ctx);
333
380
  await ctx.waitForIdle();
@@ -1393,6 +1440,16 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1393
1440
  return;
1394
1441
  }
1395
1442
  const argsWithoutSubagent = subagent.args;
1443
+ if (prompt.deterministic) {
1444
+ if (subagent.override || subagent.fork) {
1445
+ notify(ctx, `Deterministic prompts do not support runtime --subagent/--fork in v1`, "error");
1446
+ return;
1447
+ }
1448
+ if (extractLoopCount(argsWithoutSubagent)) {
1449
+ notify(ctx, `Deterministic prompts do not support runtime --loop in v1`, "error");
1450
+ return;
1451
+ }
1452
+ }
1396
1453
 
1397
1454
  const hasCompareLineup = prompt.workers !== undefined || prompt.reviewers !== undefined || prompt.finalApplier !== undefined;
1398
1455
  if (hasCompareLineup) {
@@ -1482,7 +1539,14 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1482
1539
  return;
1483
1540
  }
1484
1541
 
1485
- const effectivePrompt = { ...prompt, ...(runtimeCwd ? { cwd: runtimeCwd } : {}), ...promptOverrides };
1542
+ const effectivePrompt = {
1543
+ ...prompt,
1544
+ ...(runtimeCwd ? {
1545
+ cwd: runtimeCwd,
1546
+ ...(prompt.deterministic ? { deterministic: { ...prompt.deterministic, cwd: runtimeCwd } } : {}),
1547
+ } : {}),
1548
+ ...promptOverrides,
1549
+ };
1486
1550
  const savedModel = getCurrentModel(ctx);
1487
1551
  const savedThinking = pi.getThinkingLevel();
1488
1552
  const stepResult = await executePromptStep(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-prompt-template-model",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Prompt template model selector extension for pi coding agent",
6
6
  "author": "Nico Bailon",
@@ -30,6 +30,8 @@
30
30
  "notifications.ts",
31
31
  "prompt-execution.ts",
32
32
  "prompt-loader.ts",
33
+ "deterministic-step.ts",
34
+ "deterministic-renderer.ts",
33
35
  "subagent-renderer.ts",
34
36
  "subagent-runtime.ts",
35
37
  "subagent-step.ts",
package/prompt-loader.ts CHANGED
@@ -41,6 +41,24 @@ export interface DelegationLineupSlot {
41
41
  count?: number;
42
42
  }
43
43
 
44
+ export type DeterministicHandoff = "always" | "never" | "on-success" | "on-failure";
45
+
46
+ export type DeterministicExecution =
47
+ | { kind: "run"; command: string }
48
+ | { kind: "command"; command: string; args: string[]; shell: boolean }
49
+ | { kind: "script"; path: string; args: string[] };
50
+
51
+ export type DeterministicEnv = Record<string, string>;
52
+
53
+ export interface DeterministicStep {
54
+ execution: DeterministicExecution;
55
+ handoff: DeterministicHandoff;
56
+ nonInteractive: boolean;
57
+ timeoutMs?: number;
58
+ cwd?: string;
59
+ env?: DeterministicEnv;
60
+ }
61
+
44
62
  export interface PromptWithModel {
45
63
  name: string;
46
64
  description: string;
@@ -58,6 +76,7 @@ export interface PromptWithModel {
58
76
  converge?: boolean;
59
77
  parallel?: number;
60
78
  worktree?: boolean;
79
+ deterministic?: DeterministicStep;
61
80
  subagent?: true | string;
62
81
  inheritContext?: boolean;
63
82
  cwd?: string;
@@ -348,6 +367,378 @@ function normalizeParallel(
348
367
  return undefined;
349
368
  }
350
369
 
370
+ function normalizeStringArrayField(
371
+ field: string,
372
+ value: unknown,
373
+ filePath: string,
374
+ source: PromptSource,
375
+ diagnostics: PromptLoaderDiagnostic[],
376
+ ): string[] | undefined {
377
+ if (value === undefined) return [];
378
+ if (!Array.isArray(value)) {
379
+ diagnostics.push(
380
+ createDiagnostic(
381
+ `invalid-${field}`,
382
+ filePath,
383
+ source,
384
+ `Ignoring invalid ${field} value in ${filePath}: expected an array of strings.`,
385
+ ),
386
+ );
387
+ return undefined;
388
+ }
389
+
390
+ const args: string[] = [];
391
+ for (const entry of value) {
392
+ if (typeof entry !== "string") {
393
+ diagnostics.push(
394
+ createDiagnostic(
395
+ `invalid-${field}`,
396
+ filePath,
397
+ source,
398
+ `Ignoring invalid ${field} value in ${filePath}: expected an array of strings.`,
399
+ ),
400
+ );
401
+ return undefined;
402
+ }
403
+ args.push(entry);
404
+ }
405
+ return args;
406
+ }
407
+
408
+ function normalizeDeterministicHandoff(
409
+ value: unknown,
410
+ filePath: string,
411
+ source: PromptSource,
412
+ diagnostics: PromptLoaderDiagnostic[],
413
+ ): DeterministicHandoff {
414
+ if (value === undefined) return "always";
415
+ if (typeof value === "string") {
416
+ const normalized = value.trim().toLowerCase();
417
+ if (normalized === "always" || normalized === "never" || normalized === "on-success" || normalized === "on-failure") {
418
+ return normalized;
419
+ }
420
+ }
421
+
422
+ diagnostics.push(
423
+ createDiagnostic(
424
+ "invalid-deterministic-handoff",
425
+ filePath,
426
+ source,
427
+ `Using default deterministic handoff=always for ${filePath}: expected "always", "never", "on-success", or "on-failure".`,
428
+ ),
429
+ );
430
+ return "always";
431
+ }
432
+
433
+ function normalizeTimeoutMs(
434
+ value: unknown,
435
+ filePath: string,
436
+ source: PromptSource,
437
+ diagnostics: PromptLoaderDiagnostic[],
438
+ ): number | undefined {
439
+ if (value === undefined) return undefined;
440
+ let timeoutMs: number | undefined;
441
+ if (typeof value === "number") timeoutMs = value;
442
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) timeoutMs = parseInt(value.trim(), 10);
443
+ if (timeoutMs !== undefined && Number.isInteger(timeoutMs) && timeoutMs >= 1) return timeoutMs;
444
+
445
+ diagnostics.push(
446
+ createDiagnostic(
447
+ "invalid-deterministic-timeout",
448
+ filePath,
449
+ source,
450
+ `Ignoring invalid deterministic timeout in ${filePath}: expected an integer greater than or equal to 1 (milliseconds).`,
451
+ ),
452
+ );
453
+ return undefined;
454
+ }
455
+
456
+ function normalizeDeterministicEnv(
457
+ value: unknown,
458
+ filePath: string,
459
+ source: PromptSource,
460
+ diagnostics: PromptLoaderDiagnostic[],
461
+ ): DeterministicEnv | undefined {
462
+ if (value === undefined) return undefined;
463
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
464
+ diagnostics.push(
465
+ createDiagnostic(
466
+ "invalid-deterministic-env",
467
+ filePath,
468
+ source,
469
+ `Ignoring invalid deterministic env in ${filePath}: expected an object with string/number/boolean values.`,
470
+ ),
471
+ );
472
+ return undefined;
473
+ }
474
+
475
+ const env: DeterministicEnv = {};
476
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
477
+ if (!key.trim()) {
478
+ diagnostics.push(
479
+ createDiagnostic(
480
+ "invalid-deterministic-env",
481
+ filePath,
482
+ source,
483
+ `Ignoring invalid deterministic env in ${filePath}: env keys must be non-empty strings.`,
484
+ ),
485
+ );
486
+ return undefined;
487
+ }
488
+ if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean") {
489
+ diagnostics.push(
490
+ createDiagnostic(
491
+ "invalid-deterministic-env",
492
+ filePath,
493
+ source,
494
+ `Ignoring invalid deterministic env in ${filePath}: env value for ${JSON.stringify(key)} must be a string, number, or boolean.`,
495
+ ),
496
+ );
497
+ return undefined;
498
+ }
499
+ env[key] = String(raw);
500
+ }
501
+
502
+ return Object.keys(env).length > 0 ? env : undefined;
503
+ }
504
+
505
+ function normalizeDeterministicNonInteractive(
506
+ value: unknown,
507
+ filePath: string,
508
+ source: PromptSource,
509
+ diagnostics: PromptLoaderDiagnostic[],
510
+ ): boolean {
511
+ if (value === undefined) return true;
512
+ if (typeof value === "boolean") return value;
513
+ if (typeof value === "string") {
514
+ const normalized = value.trim().toLowerCase();
515
+ if (normalized === "true") return true;
516
+ if (normalized === "false") return false;
517
+ }
518
+
519
+ diagnostics.push(
520
+ createDiagnostic(
521
+ "invalid-deterministic-non-interactive",
522
+ filePath,
523
+ source,
524
+ `Using default deterministic nonInteractive=true for ${filePath}: expected true or false.`,
525
+ ),
526
+ );
527
+ return true;
528
+ }
529
+
530
+ function normalizeDeterministicRunValue(
531
+ value: unknown,
532
+ filePath: string,
533
+ source: PromptSource,
534
+ diagnostics: PromptLoaderDiagnostic[],
535
+ ): DeterministicExecution | undefined {
536
+ if (typeof value === "string") {
537
+ const command = value.trim();
538
+ if (command) return { kind: "run", command };
539
+ diagnostics.push(
540
+ createDiagnostic(
541
+ "invalid-deterministic-run",
542
+ filePath,
543
+ source,
544
+ `Ignoring invalid deterministic run value in ${filePath}: expected a non-empty string or an object with command/args.`,
545
+ ),
546
+ );
547
+ return undefined;
548
+ }
549
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
550
+ diagnostics.push(
551
+ createDiagnostic(
552
+ "invalid-deterministic-run",
553
+ filePath,
554
+ source,
555
+ `Ignoring invalid deterministic run value in ${filePath}: expected a non-empty string or an object with command/args.`,
556
+ ),
557
+ );
558
+ return undefined;
559
+ }
560
+
561
+ const record = value as Record<string, unknown>;
562
+ const command = normalizeStringField("deterministic.run.command", record.command, filePath, source, diagnostics);
563
+ if (!command) {
564
+ diagnostics.push(
565
+ createDiagnostic(
566
+ "invalid-deterministic-run",
567
+ filePath,
568
+ source,
569
+ `Ignoring invalid deterministic run value in ${filePath}: expected object field "command" to be a non-empty string.`,
570
+ ),
571
+ );
572
+ return undefined;
573
+ }
574
+ const args = normalizeStringArrayField("deterministic.run.args", record.args, filePath, source, diagnostics);
575
+ if (!args) return undefined;
576
+ let shell = false;
577
+ if (record.shell !== undefined) {
578
+ if (typeof record.shell === "boolean") {
579
+ shell = record.shell;
580
+ } else {
581
+ diagnostics.push(
582
+ createDiagnostic(
583
+ "invalid-deterministic-run",
584
+ filePath,
585
+ source,
586
+ `Ignoring invalid deterministic run value in ${filePath}: object field "shell" must be true or false.`,
587
+ ),
588
+ );
589
+ return undefined;
590
+ }
591
+ }
592
+ return { kind: "command", command, args, shell };
593
+ }
594
+
595
+ function normalizeDeterministicScriptValue(
596
+ value: unknown,
597
+ filePath: string,
598
+ source: PromptSource,
599
+ diagnostics: PromptLoaderDiagnostic[],
600
+ ): DeterministicExecution | undefined {
601
+ if (typeof value === "string") {
602
+ const path = value.trim();
603
+ if (path) return { kind: "script", path, args: [] };
604
+ diagnostics.push(
605
+ createDiagnostic(
606
+ "invalid-deterministic-script",
607
+ filePath,
608
+ source,
609
+ `Ignoring invalid deterministic script value in ${filePath}: expected a non-empty string or an object with path/args.`,
610
+ ),
611
+ );
612
+ return undefined;
613
+ }
614
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
615
+ diagnostics.push(
616
+ createDiagnostic(
617
+ "invalid-deterministic-script",
618
+ filePath,
619
+ source,
620
+ `Ignoring invalid deterministic script value in ${filePath}: expected a non-empty string or an object with path/args.`,
621
+ ),
622
+ );
623
+ return undefined;
624
+ }
625
+
626
+ const record = value as Record<string, unknown>;
627
+ const path = normalizeStringField("deterministic.script.path", record.path, filePath, source, diagnostics);
628
+ if (!path) {
629
+ diagnostics.push(
630
+ createDiagnostic(
631
+ "invalid-deterministic-script",
632
+ filePath,
633
+ source,
634
+ `Ignoring invalid deterministic script value in ${filePath}: expected object field "path" to be a non-empty string.`,
635
+ ),
636
+ );
637
+ return undefined;
638
+ }
639
+ const args = normalizeStringArrayField("deterministic.script.args", record.args, filePath, source, diagnostics);
640
+ if (!args) return undefined;
641
+ return { kind: "script", path, args };
642
+ }
643
+
644
+ function normalizeDeterministic(
645
+ frontmatter: Record<string, unknown>,
646
+ filePath: string,
647
+ source: PromptSource,
648
+ diagnostics: PromptLoaderDiagnostic[],
649
+ ): DeterministicStep | undefined {
650
+ const hasNested = Object.hasOwn(frontmatter, "deterministic");
651
+ const hasRun = Object.hasOwn(frontmatter, "run");
652
+ const hasScript = Object.hasOwn(frontmatter, "script");
653
+ const hasHandoff = Object.hasOwn(frontmatter, "handoff");
654
+ const hasTimeout = Object.hasOwn(frontmatter, "timeout");
655
+ const hasEnv = Object.hasOwn(frontmatter, "env");
656
+ const hasNonInteractive = Object.hasOwn(frontmatter, "nonInteractive");
657
+ if (!hasNested && !hasRun && !hasScript && !hasHandoff && !hasTimeout && !hasEnv && !hasNonInteractive) return undefined;
658
+
659
+ if (hasNested && (hasRun || hasScript || hasHandoff || hasTimeout || hasEnv || hasNonInteractive)) {
660
+ diagnostics.push(
661
+ createDiagnostic(
662
+ "invalid-deterministic-mixed-shorthand",
663
+ filePath,
664
+ source,
665
+ `Ignoring top-level deterministic shorthand in ${filePath}: use either "deterministic" or top-level run/script/handoff/timeout/env/nonInteractive, not both.`,
666
+ ),
667
+ );
668
+ }
669
+
670
+ let record: Record<string, unknown>;
671
+ if (hasNested) {
672
+ const raw = frontmatter.deterministic;
673
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
674
+ diagnostics.push(
675
+ createDiagnostic(
676
+ "invalid-deterministic",
677
+ filePath,
678
+ source,
679
+ `Ignoring invalid deterministic config in ${filePath}: frontmatter field "deterministic" must be an object.`,
680
+ ),
681
+ );
682
+ return undefined;
683
+ }
684
+ record = raw as Record<string, unknown>;
685
+ } else {
686
+ record = {
687
+ run: frontmatter.run,
688
+ script: frontmatter.script,
689
+ handoff: frontmatter.handoff,
690
+ timeout: frontmatter.timeout,
691
+ env: frontmatter.env,
692
+ nonInteractive: frontmatter.nonInteractive,
693
+ };
694
+ }
695
+
696
+ const runValue = Object.hasOwn(record, "run") ? record.run : undefined;
697
+ const scriptValue = Object.hasOwn(record, "script") ? record.script : undefined;
698
+ if (runValue !== undefined && scriptValue !== undefined) {
699
+ diagnostics.push(
700
+ createDiagnostic(
701
+ "invalid-deterministic",
702
+ filePath,
703
+ source,
704
+ `Ignoring deterministic config in ${filePath}: "run" and "script" cannot be declared together.`,
705
+ ),
706
+ );
707
+ return undefined;
708
+ }
709
+
710
+ const execution = runValue !== undefined
711
+ ? normalizeDeterministicRunValue(runValue, filePath, source, diagnostics)
712
+ : scriptValue !== undefined
713
+ ? normalizeDeterministicScriptValue(scriptValue, filePath, source, diagnostics)
714
+ : undefined;
715
+ if (!execution) {
716
+ diagnostics.push(
717
+ createDiagnostic(
718
+ "invalid-deterministic",
719
+ filePath,
720
+ source,
721
+ `Ignoring deterministic config in ${filePath}: expected either "run" or "script".`,
722
+ ),
723
+ );
724
+ return undefined;
725
+ }
726
+
727
+ const handoff = normalizeDeterministicHandoff(record.handoff, filePath, source, diagnostics);
728
+ const timeoutMs = normalizeTimeoutMs(record.timeout, filePath, source, diagnostics);
729
+ const cwd = normalizeCwd(record.cwd, filePath, source, diagnostics);
730
+ const env = normalizeDeterministicEnv(record.env, filePath, source, diagnostics);
731
+ const nonInteractive = normalizeDeterministicNonInteractive(record.nonInteractive, filePath, source, diagnostics);
732
+ return {
733
+ execution,
734
+ handoff,
735
+ nonInteractive,
736
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
737
+ ...(cwd ? { cwd } : {}),
738
+ ...(env ? { env } : {}),
739
+ };
740
+ }
741
+
351
742
  function normalizeLineupSlot(
352
743
  value: unknown,
353
744
  field: "workers" | "reviewers" | "finalApplier",
@@ -1033,6 +1424,7 @@ function loadPromptsWithModelFromDir(
1033
1424
  const parallel = normalizeParallel(frontmatter.parallel, fullPath, source, diagnostics);
1034
1425
  const hasBestOfN = Object.hasOwn(frontmatter, "bestOfN");
1035
1426
  const bestOfN = normalizeBestOfN(frontmatter.bestOfN, fullPath, source, diagnostics);
1427
+ let deterministic = normalizeDeterministic(frontmatter, fullPath, source, diagnostics);
1036
1428
  const hasLegacyWorkers = Object.hasOwn(frontmatter, "workers");
1037
1429
  const hasLegacyReviewers = Object.hasOwn(frontmatter, "reviewers");
1038
1430
  const hasLegacyFinalApplier = Object.hasOwn(frontmatter, "finalApplier");
@@ -1073,6 +1465,17 @@ function loadPromptsWithModelFromDir(
1073
1465
  );
1074
1466
  subagent = undefined;
1075
1467
  }
1468
+ if (chain && deterministic !== undefined) {
1469
+ diagnostics.push(
1470
+ createDiagnostic(
1471
+ "invalid-deterministic-chain",
1472
+ fullPath,
1473
+ source,
1474
+ `Ignoring deterministic config in ${fullPath}: frontmatter field "deterministic" cannot be combined with "chain".`,
1475
+ ),
1476
+ );
1477
+ deterministic = undefined;
1478
+ }
1076
1479
  if (chain && (safeWorkers !== undefined || safeReviewers !== undefined || safeFinalApplier !== undefined)) {
1077
1480
  diagnostics.push(
1078
1481
  createDiagnostic(
@@ -1099,6 +1502,17 @@ function loadPromptsWithModelFromDir(
1099
1502
  safeReviewers = undefined;
1100
1503
  safeFinalApplier = undefined;
1101
1504
  }
1505
+ if (subagent !== undefined && deterministic !== undefined) {
1506
+ diagnostics.push(
1507
+ createDiagnostic(
1508
+ "invalid-deterministic-subagent",
1509
+ fullPath,
1510
+ source,
1511
+ `Ignoring deterministic config in ${fullPath}: frontmatter field "deterministic" cannot be combined with "subagent".`,
1512
+ ),
1513
+ );
1514
+ deterministic = undefined;
1515
+ }
1102
1516
  if (subagent === undefined && inheritContext) {
1103
1517
  diagnostics.push(
1104
1518
  createDiagnostic(
@@ -1145,6 +1559,17 @@ function loadPromptsWithModelFromDir(
1145
1559
  safeReviewers = undefined;
1146
1560
  safeFinalApplier = undefined;
1147
1561
  }
1562
+ if (safeParallel !== undefined && deterministic !== undefined) {
1563
+ diagnostics.push(
1564
+ createDiagnostic(
1565
+ "invalid-deterministic-parallel",
1566
+ fullPath,
1567
+ source,
1568
+ `Ignoring deterministic config in ${fullPath}: frontmatter field "deterministic" cannot be combined with "parallel".`,
1569
+ ),
1570
+ );
1571
+ deterministic = undefined;
1572
+ }
1148
1573
  const hasLineup = safeWorkers !== undefined || safeReviewers !== undefined || safeFinalApplier !== undefined;
1149
1574
  if (!hasBestOfN && hasLegacyCompareFields) {
1150
1575
  diagnostics.push(
@@ -1169,14 +1594,18 @@ function loadPromptsWithModelFromDir(
1169
1594
  continue;
1170
1595
  }
1171
1596
  if (!chain && subagent === undefined && !hasLineup && cwd) {
1172
- diagnostics.push(
1173
- createDiagnostic(
1174
- "invalid-cwd",
1175
- fullPath,
1176
- source,
1177
- `Ignoring cwd in ${fullPath}: frontmatter field "cwd" requires "subagent", "chain", or compare lineups ("workers"/"reviewers"/"finalApplier").`,
1178
- ),
1179
- );
1597
+ if (deterministic) {
1598
+ deterministic = { ...deterministic, ...(deterministic.cwd ? {} : { cwd }) };
1599
+ } else {
1600
+ diagnostics.push(
1601
+ createDiagnostic(
1602
+ "invalid-cwd",
1603
+ fullPath,
1604
+ source,
1605
+ `Ignoring cwd in ${fullPath}: frontmatter field "cwd" requires "subagent", "chain", or compare lineups ("workers"/"reviewers"/"finalApplier").`,
1606
+ ),
1607
+ );
1608
+ }
1180
1609
  }
1181
1610
  const hasModelField = Object.hasOwn(frontmatter, "model");
1182
1611
  const parsedModels = chain ? [] : normalizeModelSpecs(frontmatter.model, fullPath, source, diagnostics);
@@ -1214,6 +1643,17 @@ function loadPromptsWithModelFromDir(
1214
1643
  const fresh = normalizeFresh(frontmatter.fresh, fullPath, source, diagnostics);
1215
1644
  const loop = normalizeLoop(frontmatter.loop, fullPath, source, diagnostics);
1216
1645
  const converge = normalizeConverge(frontmatter.converge, fullPath, source, diagnostics);
1646
+ if (loop !== undefined && deterministic !== undefined) {
1647
+ diagnostics.push(
1648
+ createDiagnostic(
1649
+ "invalid-deterministic-loop",
1650
+ fullPath,
1651
+ source,
1652
+ `Ignoring deterministic config in ${fullPath}: frontmatter field "deterministic" cannot be combined with "loop" in v1.`,
1653
+ ),
1654
+ );
1655
+ deterministic = undefined;
1656
+ }
1217
1657
  const worktreeInput = hasBestOfN ? bestOfN?.worktree : frontmatter.worktree;
1218
1658
  const worktree = normalizeWorktree(worktreeInput, fullPath, source, diagnostics);
1219
1659
  let safeWorktree: boolean | undefined;
@@ -1256,6 +1696,7 @@ function loadPromptsWithModelFromDir(
1256
1696
  loop !== undefined ||
1257
1697
  converge === false ||
1258
1698
  safeParallel !== undefined ||
1699
+ deterministic !== undefined ||
1259
1700
  hasLineup ||
1260
1701
  safeWorktree === true ||
1261
1702
  subagent !== undefined ||
@@ -1282,6 +1723,7 @@ function loadPromptsWithModelFromDir(
1282
1723
  converge: converge === false ? false : undefined,
1283
1724
  parallel: safeParallel,
1284
1725
  worktree: safeWorktree,
1726
+ deterministic,
1285
1727
  subagent,
1286
1728
  inheritContext: safeInheritContext || undefined,
1287
1729
  cwd: safeCwd || undefined,
@@ -1381,6 +1823,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
1381
1823
  const loopLabel = prompt.loop !== undefined ? ` loop:${prompt.loop === null ? "unlimited" : prompt.loop}` : "";
1382
1824
  const subagentLabel = prompt.subagent ? ` subagent:${prompt.subagent === true ? "delegate" : prompt.subagent}` : "";
1383
1825
  const parallelLabel = prompt.parallel !== undefined ? ` parallel:${prompt.parallel}` : "";
1826
+ const deterministicLabel = prompt.deterministic ? ` deterministic-step:${prompt.deterministic.handoff}` : "";
1384
1827
  const workersLabel = prompt.workers ? ` workers:${effectiveLineupCount(prompt.workers)}` : "";
1385
1828
  const reviewersLabel = prompt.reviewers ? ` reviewers:${effectiveLineupCount(prompt.reviewers)}` : "";
1386
1829
  const finalApplierLabel = prompt.finalApplier ? " final-applier" : "";
@@ -1388,7 +1831,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
1388
1831
  const inheritContextLabel = prompt.inheritContext ? " fork" : "";
1389
1832
  const worktreeLabel = prompt.worktree ? " worktree" : "";
1390
1833
  const details =
1391
- `[${modelLabel}${rotateLabel}${thinkingLabel}${skillLabel}${loopLabel}${subagentLabel}${parallelLabel}${workersLabel}${reviewersLabel}${finalApplierLabel}${cwdLabel}${inheritContextLabel}${worktreeLabel}] ${sourceLabel}`;
1834
+ `[${modelLabel}${rotateLabel}${thinkingLabel}${skillLabel}${loopLabel}${subagentLabel}${parallelLabel}${deterministicLabel}${workersLabel}${reviewersLabel}${finalApplierLabel}${cwdLabel}${inheritContextLabel}${worktreeLabel}] ${sourceLabel}`;
1392
1835
  return prompt.description ? `${prompt.description} ${details}` : details;
1393
1836
  }
1394
1837