pi-usereq 0.40.0 → 0.41.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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "PI-useReq Requirements"
3
3
  description: Software requirements specification
4
- version: "0.0.71"
4
+ version: "0.0.73"
5
5
  date: "2026-07-10"
6
6
  author: "OpenAI Codex"
7
7
  scope:
@@ -162,6 +162,7 @@ PI-useReq is a TypeScript pi extension plus companion Node CLI and standalone ex
162
162
  - **REQ-335**: MUST display a command invocation summary on screen for every bundled prompt-backed `req-<prompt>` command containing the command name, passed arguments, and active configuration.
163
163
  - **REQ-336**: MUST render the command invocation summary with the command name without the `req-` prefix in uppercase and the user request arguments.
164
164
  - **REQ-337**: MUST include `docs-dir`, `src-dir`, `tests-dir`, enabled context files, `AUTO_GIT_COMMIT`, effective `GIT_WORKTREE_ENABLED`, `GIT_WORKTREE_PREFIX`, enabled static-check languages, and `enabled-tools` in the command invocation summary.
165
+ - **REQ-338**: MUST render `none` for the command invocation summary `context files`, `static code checks`, and `enabled tools` fields whenever their respective enabled-item list is empty.
165
166
  - **REQ-008**: MUST provide a `Language static code checkers` submenu that adds global Command entries by guided language flow, removes configured global checker entries, toggles local per-language enablement, and resets static-check configuration.
166
167
  - **REQ-160**: MUST hardcode `Command` as the only user-configurable static-check module and omit module-selection UI from static-check configuration menus.
167
168
  - **REQ-161**: MUST hide `Dummy` from user-configurable static-check menus while preserving existing-config parsing and debug-driver support for `Dummy` entries.
@@ -395,12 +396,12 @@ PI-useReq is a TypeScript pi extension plus companion Node CLI and standalone ex
395
396
  - **REQ-325**: MUST reject `debug-compress`, `debug-references`, `debug-static-check`, `debug-summarize`, and `debug-tokens` execution when `DEBUG_TOOL_COMMANDS_ENABLED=disable`.
396
397
  - **REQ-326**: MUST add a top-level `Context Files` row to the `pi-usereq` menu between `Unit tests directory` and `Auto git commit`.
397
398
  - **REQ-327**: MUST expose a `Context Files` submenu with separate enable toggles for `REQUIREMENTS.md`, `REFERENCES.md`, and `WORKFLOW.md` in that order.
398
- - **REQ-328**: MUST persist `context-files-requirements`, `context-files-references`, and `context-files-workflow` as boolean flags in local configuration, each defaulting to enabled.
399
+ - **REQ-328**: MUST persist `context-files-requirements`, `context-files-references`, and `context-files-workflow` as boolean flags in local configuration, each defaulting to disabled.
399
400
  - **REQ-329**: MUST replace the `%%CONTEXT_FILES%%` prompt token with one markdown section per enabled context file in the order `REQUIREMENTS.md`, `REFERENCES.md`, `WORKFLOW.md`.
400
401
  - **REQ-330**: MUST render each `%%CONTEXT_FILES%%` section as the file-name heading, an HTML `<file name="<docs-dir>/<filename>">` reference with `%%DOC_PATH%%` pre-substituted, and the raw file content inside four-backtick `markdown` fences.
401
402
  - **REQ-331**: MUST omit the `%%CONTEXT_FILES%%` section for any disabled flag or missing context file without surfacing an error.
402
403
  - **REQ-332**: MUST inject `%%CONTEXT_FILES%%` content verbatim after every other prompt replacement so literal `%%...%%` tokens inside context files are not substituted.
403
- - **REQ-333**: MUST restore all three `Context Files` flags to enabled when the `Context Files` subtree `Reset defaults` is approved.
404
+ - **REQ-333**: MUST restore all three `Context Files` flags to disabled when the `Context Files` subtree `Reset defaults` is approved.
404
405
 
405
406
  ## 4. Test Requirements
406
407
  - **TST-001**: MUST verify extension activation registers every documented prompt command, agent tool, and configuration command while omitting tool-name slash commands, `test-static-check`, and the removed standalone config-viewer command.
@@ -522,6 +523,7 @@ PI-useReq is a TypeScript pi extension plus companion Node CLI and standalone ex
522
523
  - **TST-118**: MUST verify the `Context Files` submenu persists the three context-file flags in local configuration with immediate-save, reset, and focus-preserving re-render behavior.
523
524
  - **TST-119**: MUST verify `%%CONTEXT_FILES%%` replacement emits one section per enabled existing file in the documented order and omits disabled or missing files.
524
525
  - **TST-120**: MUST verify `%%CONTEXT_FILES%%` sections use the file-name heading, the pre-substituted HTML file reference, and four-backtick `markdown` fences around raw content.
526
+ - **TST-122**: MUST verify the command invocation summary renders `none` for `context files`, `static code checks`, and `enabled tools` when no items are enabled in each respective category.
525
527
 
526
528
  ## 5. Observed Component Model
527
529
 
@@ -575,8 +575,8 @@
575
575
  - `buildIgnoredGitStatusPaths(...)`: ignore extension-owned debug-log artifacts during cleanliness verification [`src/core/req-references-command.ts`]
576
576
  - `renderPrompt(...)`: render prompt-command payloads with bundled commit-instruction expansion, conditional pi.dev governance guidance, and `%%CONTEXT_FILES%%` context-file injection [`src/core/prompts.ts`]
577
577
  - `buildContextFilesBlock(...)`: build the markdown block injected at `%%CONTEXT_FILES%%` from enabled context files in documented order with pre-substituted `%%DOC_PATH%%` file references and four-backtick fences [`src/core/prompts.ts`]
578
- - `renderPromptCommandSummary(...)`: render the on-screen command invocation summary with command name, user request arguments, and active configuration fields [`src/core/prompts.ts`]
579
- - `deliverPromptCommand(...)`: dispatch the rendered prompt as a hidden `display:false` custom message plus a `display:true` summary into the current recorded session, falling back to `sendUserMessage(...)` when `sendMessage(...)` is unavailable [`src/index.ts`]
578
+ - `renderPromptCommandSummary(...)`: render the on-screen command invocation summary with command name, user request arguments, and active configuration fields, rendering `none` for empty context-file, static-check-language, and enabled-tool lists [`src/core/prompts.ts`]
579
+ - `deliverPromptCommand(...)`: dispatch the rendered prompt as a hidden `display:false` custom message plus a `display:true` summary into the current recorded session, awaiting and suppressing summary delivery failures before the hidden prompt turn, falling back to `sendUserMessage(...)` when `sendMessage(...)` is unavailable [`src/index.ts`]
580
580
  - External boundaries: `RecordingExtensionAPI.sendMessage(...)` or fallback `RecordingExtensionAPI.sendUserMessage(...)` records the delivered prompt payload plus summary.
581
581
  - `configurePiUsereq(...)`: execute the interactive configuration menu [`src/index.ts`]
582
582
  - `buildPiUsereqMenuChoices(...)`: serialize top-level settings actions including the `Context Files` injection toggle row, effective worktree locking, plus display-only local and global config-path rows [`src/index.ts`]
@@ -880,8 +880,8 @@
880
880
  - `writePersistedPromptCommandSessionContext(...)`: store the reusable command-capable replacement-session context for later closure handling [`src/core/prompt-command-state.ts`]
881
881
  - `clearPersistedPromptCommandSessionContext(...)`: drop reusable command-context persistence when worktree routing is inactive [`src/core/prompt-command-state.ts`]
882
882
  - `logPromptWorkflowEvent(...)`: append selected workflow-activation debug entries [`src/index.ts`]
883
- - `renderPromptCommandSummary(...)`: render the on-screen command invocation summary with command name without the `req-` prefix in uppercase, user request arguments, and active configuration fields [`src/core/prompts.ts`]
884
- - `deliverPromptCommand(...)`: start dispatch of the rendered prompt as a hidden `display:false` custom message with `triggerTurn:true` plus a `display:true` command invocation summary into the current active session after execution-session activation is verified, preferring replacement-session `sendMessage(...)` when available, falling back to `sendUserMessage(...)` when `sendMessage(...)` is unavailable, returning the delivery promise so the final `running` transition is recorded immediately after handoff begins instead of after the full prompt run completes, and suppressing the documented stale post-restore rejection once the prompt was already accepted [`src/index.ts`]
883
+ - `renderPromptCommandSummary(...)`: render the on-screen command invocation summary with command name without the `req-` prefix in uppercase, user request arguments, and active configuration fields, rendering `none` for empty context-file, static-check-language, and enabled-tool lists [`src/core/prompts.ts`]
884
+ - `deliverPromptCommand(...)`: start dispatch of the rendered prompt as a hidden `display:false` custom message with `triggerTurn:true` plus a `display:true` command invocation summary into the current active session after execution-session activation is verified, preferring replacement-session `sendMessage(...)` when available, falling back to `sendUserMessage(...)` when `sendMessage(...)` is unavailable, awaiting and suppressing the non-critical summary delivery before the authoritative hidden prompt turn so a rejecting summary never surfaces as an unhandled rejection that could terminate the process before prompt-end closure runs the worktree merge, returning the delivery promise so the final `running` transition is recorded immediately after handoff begins instead of after the full prompt run completes, and suppressing the documented stale post-restore rejection once the prompt was already accepted [`src/index.ts`]
885
885
  - `writePersistedPromptCommandRuntimeState(...)`: mirror the prepared prompt execution plan into process-scoped persistence before the worktree session switch returns control [`src/core/prompt-command-state.ts`]
886
886
  - `isStaleExtensionContextError(...)`: classify stale post-restore delivery rejections from replacement-session `sendMessage(...)` [`src/core/extension-status.ts`]
887
887
  - External boundaries: replacement-session `sendMessage(...)` or `pi.sendMessage(...)` delivers the hidden rendered prompt payload plus the displayed summary into the active `AgentSession`; fallback replacement-session `sendUserMessage(...)` or `pi.sendUserMessage(...)` delivers the rendered prompt payload when `sendMessage(...)` is unavailable.
@@ -214,14 +214,14 @@ export const DEFAULT_TESTS_DIR = "tests";
214
214
  export const DEFAULT_SRC_DIRS = ["src"];
215
215
  /**
216
216
  * @brief Defines the default value for every persisted context-file injection flag.
217
- * @details Each `%%CONTEXT_FILES%%` injection toggle defaults to enabled so the bundled canonical documents remain part of the prompt context unless the user explicitly disables one. Lookup complexity is O(1).
217
+ * @details Each `%%CONTEXT_FILES%%` injection toggle defaults to disabled so the bundled canonical documents are excluded from the prompt context unless the user explicitly enables one. Lookup complexity is O(1).
218
218
  * @satisfies REQ-328
219
219
  */
220
- export const DEFAULT_CONTEXT_FILES_FLAG = true;
220
+ export const DEFAULT_CONTEXT_FILES_FLAG = false;
221
221
 
222
222
  /**
223
223
  * @brief Normalizes one persisted context-file injection flag.
224
- * @details Returns the documented default (`true`) for missing or non-boolean values and preserves only explicit boolean input, so any malformed persisted entry keeps context-file injection enabled. Runtime is O(1). No external state is mutated.
224
+ * @details Returns the documented default (`false`) for missing or non-boolean values and preserves only explicit boolean input, so any malformed persisted entry keeps context-file injection disabled. Runtime is O(1). No external state is mutated.
225
225
  * @param[in] value {unknown} Candidate persisted context-file flag.
226
226
  * @return {boolean} Normalized context-file injection flag.
227
227
  * @satisfies REQ-328
@@ -6,7 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { spawnSync } from "node:child_process";
9
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
10
10
  import { ReqError } from "./errors.js";
11
11
  import { classifyPiNotifyOutcome, type PiNotifyOutcome } from "./pi-notify.js";
12
12
  import {
@@ -20,6 +20,7 @@ import {
20
20
  type DebugWorkflowState,
21
21
  } from "./debug-runtime.js";
22
22
  import { type PromptCommandName } from "./prompt-command-catalog.js";
23
+ export { type PromptCommandName } from "./prompt-command-catalog.js";
23
24
  import {
24
25
  isSameOrAncestorPath,
25
26
  normalizeRelativeDirContract,
@@ -119,14 +120,13 @@ interface PromptCommandActiveContext extends PromptCommandSessionContext {
119
120
 
120
121
  /**
121
122
  * @brief Describes one serializable session entry copied into a materialized execution-session file.
122
- * @details Captures the stable tree-entry fields needed to write a JSONL session snapshot for cross-cwd session replacement when the origin session file has not been flushed yet. The interface is compile-time only and introduces no runtime cost.
123
+ * @details Captures the stable tree-entry fields needed to write a JSONL session snapshot for cross-cwd session replacement when the origin session file has not been flushed yet. Mirrors the SDK `SessionEntryBase` shape without an index signature so the SDK `SessionEntry` union remains structurally assignable for passthrough serialization. The interface is compile-time only and introduces no runtime cost.
123
124
  */
124
125
  interface PromptCommandSessionEntry {
125
126
  type: string;
126
127
  id: string;
127
128
  parentId: string | null;
128
129
  timestamp: string;
129
- [key: string]: unknown;
130
130
  }
131
131
 
132
132
  /**
@@ -705,7 +705,7 @@ async function switchPromptCommandSession(
705
705
  throw new ReqError(`ERROR: Prompt orchestration requires ctx.switchSession() for ${sessionFile}.`, 1);
706
706
  }
707
707
  let replacementContext: PromptCommandActiveContext | undefined;
708
- let switchResult: { cancelled?: boolean } | void;
708
+ let switchResult: { cancelled?: boolean } | void = undefined;
709
709
  try {
710
710
  switchResult = await ctx.switchSession(sessionFile, {
711
711
  withSession: async (activeContext) => {
@@ -840,9 +840,9 @@ const PROMPT_REQUIRED_DOCS: Record<PromptCommandName, readonly PromptRequiredDoc
840
840
  * @details Delegates to `spawnSync`, preserves the supplied working directory, and returns the raw subprocess result used by prompt-command orchestration. Runtime is dominated by external process execution. Side effects include process spawning.
841
841
  * @param[in] command {string[]} Executable plus argument vector.
842
842
  * @param[in] cwd {string} Working directory for the subprocess.
843
- * @return {ReturnType<typeof spawnSync>} Captured subprocess result.
843
+ * @return {SpawnSyncReturns<string>} Captured subprocess result with UTF-8 decoded stdout and stderr.
844
844
  */
845
- function runCapture(command: string[], cwd: string): ReturnType<typeof spawnSync> {
845
+ function runCapture(command: string[], cwd: string): SpawnSyncReturns<string> {
846
846
  return spawnSync(command[0]!, command.slice(1), {
847
847
  cwd,
848
848
  encoding: "utf8",
@@ -375,12 +375,12 @@ export const PROMPT_COMMAND_SUMMARY_CUSTOM_TYPE = "pi-usereq-prompt-command";
375
375
 
376
376
  /**
377
377
  * @brief Builds the on-screen command invocation summary for one bundled prompt-backed `req-<prompt>` command.
378
- * @details Renders the command name without the `req-` prefix in uppercase, the user request arguments, and the active configuration fields (`docs-dir`, `src-dir`, `tests-dir`, enabled context files, `AUTO_GIT_COMMIT`, effective `GIT_WORKTREE_ENABLED`, `GIT_WORKTREE_PREFIX`, enabled static-check languages, and `enabled-tools`) so the TUI shows only a compact summary while the full rendered prompt is delivered hidden to the LLM agent. Static-check languages are emitted in canonical `DEFAULT_STATIC_CHECK_LANGUAGES` order; enabled tools are emitted in documented menu order via `comparePiUsereqStartupToolNames`. Runtime is O(l + t log t) where l is language count and t is enabled-tool count. No external state is mutated.
378
+ * @details Renders the command name without the `req-` prefix in uppercase, the user request arguments, and the active configuration fields (`docs-dir`, `src-dir`, `tests-dir`, enabled context files, `AUTO_GIT_COMMIT`, effective `GIT_WORKTREE_ENABLED`, `GIT_WORKTREE_PREFIX`, enabled static-check languages, and `enabled-tools`) so the TUI shows only a compact summary while the full rendered prompt is delivered hidden to the LLM agent. Static-check languages are emitted in canonical `DEFAULT_STATIC_CHECK_LANGUAGES` order; enabled tools are emitted in documented menu order via `comparePiUsereqStartupToolNames`. The `context files`, `static code checks`, and `enabled tools` fields render the literal `none` placeholder whenever their respective enabled-item list is empty so the summary never shows a blank value. Runtime is O(l + t log t) where l is language count and t is enabled-tool count. No external state is mutated.
379
379
  * @param[in] promptName {string} Bundled prompt name without the `req-` prefix.
380
380
  * @param[in] args {string} User request arguments passed to the slash command.
381
381
  * @param[in] config {UseReqConfig} Effective project configuration supplying directory, git, static-check, and tool fields.
382
382
  * @return {string} Multi-line command invocation summary text.
383
- * @satisfies REQ-335, REQ-336, REQ-337
383
+ * @satisfies REQ-335, REQ-336, REQ-337, REQ-338
384
384
  */
385
385
  export function renderPromptCommandSummary(
386
386
  promptName: string,
@@ -390,17 +390,17 @@ export function renderPromptCommandSummary(
390
390
  const contextFiles = CONTEXT_FILE_DESCRIPTORS
391
391
  .filter((descriptor) => config[descriptor.flagKey])
392
392
  .map((descriptor) => descriptor.fileName.replace(/\.md$/, "").toLowerCase())
393
- .join(", ");
393
+ .join(", ") || "none";
394
394
  const enabledLanguages = DEFAULT_STATIC_CHECK_LANGUAGES
395
395
  .filter((language) => config["static-check"][language]?.enabled === "enable")
396
- .join(", ");
397
- const enabledTools = [...config["enabled-tools"]]
396
+ .join(", ") || "none";
397
+ const enabledTools = ([...config["enabled-tools"]]
398
398
  .sort((left, right) =>
399
399
  comparePiUsereqStartupToolNames(
400
400
  left as PiUsereqStartupToolName,
401
401
  right as PiUsereqStartupToolName,
402
402
  ))
403
- .join(", ");
403
+ .join(", ") || "none");
404
404
  const effectiveWorktree = resolveEffectiveGitWorktreeEnabled(
405
405
  config.AUTO_GIT_COMMIT,
406
406
  config.GIT_WORKTREE_ENABLED,
@@ -4,7 +4,7 @@
4
4
  * @details Performs slash-command-owned git validation reuse, reference-file generation, targeted staging, fixed-message commit creation, and post-commit cleanliness verification without creating a worktree or starting an LLM session. Runtime is dominated by git subprocess execution plus source-summary generation and one documentation write. Side effects include filesystem writes and git index/history mutation.
5
5
  */
6
6
 
7
- import { spawnSync } from "node:child_process";
7
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
8
8
  import path from "node:path";
9
9
  import type { UseReqConfig } from "./config.js";
10
10
  import { ReqError } from "./errors.js";
@@ -39,9 +39,9 @@ export interface ReqReferencesCommandPlan {
39
39
  * @details Delegates to `spawnSync(...)`, preserves the supplied working directory, and returns the raw result so callers can interpret git exit status plus diagnostics deterministically. Runtime is dominated by external process execution. Side effects include subprocess creation.
40
40
  * @param[in] command {string[]} Executable plus argument vector.
41
41
  * @param[in] cwd {string} Working directory for the subprocess.
42
- * @return {ReturnType<typeof spawnSync>} Captured subprocess result.
42
+ * @return {SpawnSyncReturns<string>} Captured subprocess result with UTF-8 stdout and stderr.
43
43
  */
44
- function runCapture(command: string[], cwd: string): ReturnType<typeof spawnSync> {
44
+ function runCapture(command: string[], cwd: string): SpawnSyncReturns<string> {
45
45
  return spawnSync(command[0]!, command.slice(1), {
46
46
  cwd,
47
47
  encoding: "utf8",
@@ -6,7 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { spawnSync } from "node:child_process";
9
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
10
10
  import {
11
11
  normalizeGitWorktreePrefix,
12
12
  type UseReqConfig,
@@ -63,9 +63,9 @@ type ReqResetCommandContext = Parameters<typeof restorePromptCommandExecution>[1
63
63
  * @details Delegates to `spawnSync(...)`, preserves the supplied working directory, and returns the raw result so callers can interpret git exit status plus diagnostics deterministically. Runtime is dominated by external process execution. Side effects include subprocess creation.
64
64
  * @param[in] command {string[]} Executable plus argument vector.
65
65
  * @param[in] cwd {string} Working directory for the subprocess.
66
- * @return {ReturnType<typeof spawnSync>} Captured subprocess result.
66
+ * @return {SpawnSyncReturns<string>} Captured subprocess result with UTF-8 stdout and stderr.
67
67
  */
68
- function runCapture(command: string[], cwd: string): ReturnType<typeof spawnSync> {
68
+ function runCapture(command: string[], cwd: string): SpawnSyncReturns<string> {
69
69
  return spawnSync(command[0]!, command.slice(1), {
70
70
  cwd,
71
71
  encoding: "utf8",
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import path from "node:path";
8
- import { spawnSync } from "node:child_process";
8
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
9
9
  import { ReqError } from "./errors.js";
10
10
  import { isSameOrAncestorPath } from "./path-context.js";
11
11
 
@@ -14,9 +14,9 @@ import { isSameOrAncestorPath } from "./path-context.js";
14
14
  * @details Delegates to `spawnSync`, keeps execution synchronous for deterministic command flows, and supports an optional working directory. Runtime is dominated by the spawned git process. Side effects include subprocess creation.
15
15
  * @param[in] command {string[]} Git executable plus argument vector.
16
16
  * @param[in] cwd {string | undefined} Optional working directory.
17
- * @return {ReturnType<typeof spawnSync>} Captured subprocess result.
17
+ * @return {SpawnSyncReturns<string>} Captured subprocess result with UTF-8 stdout and stderr.
18
18
  */
19
- function runGitCapture(command: string[], cwd?: string): ReturnType<typeof spawnSync> {
19
+ function runGitCapture(command: string[], cwd?: string): SpawnSyncReturns<string> {
20
20
  return spawnSync(command[0]!, command.slice(1), {
21
21
  cwd,
22
22
  encoding: "utf8",
package/src/index.ts CHANGED
@@ -910,7 +910,7 @@ function executeStatusTool(operation: () => ToolResult): ReturnType<typeof build
910
910
 
911
911
  /**
912
912
  * @brief Starts delivery of one rendered prompt into the current active session.
913
- * @details Prefers the replacement-session `sendMessage(...)` helper exposed by `withSession(...)` callbacks after session replacement so post-switch prompt delivery never reuses stale pre-switch session-bound extension objects. Delivers the rendered prompt as a `display:false` custom message with `triggerTurn:true` so the full content reaches the LLM agent without appearing on screen, and emits a `display:true` command invocation summary so the TUI shows only the compact summary. Returns the underlying hidden-delivery promise without awaiting it so callers can record the `running` workflow transition as soon as prompt handoff is accepted instead of waiting for the full agent turn to complete on runtimes whose async replacement-session helpers resolve only after `agent_end`. When pi later invalidates that replacement-session context during successful prompt-end restoration, the helper suppresses the documented stale-extension-context rejection because the prompt was already accepted and late rethrow would surface a false orchestration failure. Falls back to `sendUserMessage(...)` only for non-replacement flows or runtimes that do not expose `sendMessage`. Runtime is O(n) in prompt length. Side effects are limited to hidden prompt delivery plus on-screen summary display.
913
+ * @details Prefers the replacement-session `sendMessage(...)` helper exposed by `withSession(...)` callbacks after session replacement so post-switch prompt delivery never reuses stale pre-switch session-bound extension objects. Delivers the rendered prompt as a `display:false` custom message with `triggerTurn:true` so the full content reaches the LLM agent without appearing on screen, and emits a `display:true` command invocation summary so the TUI shows only the compact summary. Awaits the non-critical summary delivery and suppresses its failures before dispatching the authoritative hidden prompt turn so a rejecting summary never surfaces as an unhandled rejection that could terminate the process before prompt-end closure runs the worktree merge. Returns the underlying hidden-delivery promise without awaiting the full agent turn so callers can record the `running` workflow transition as soon as prompt handoff is accepted; the returned promise resolves after the hidden prompt message is accepted, which on runtimes whose async replacement-session helpers resolve only after `agent_end` lets prompt-end closure finalize the worktree merge during the awaited delivery. When pi later invalidates that replacement-session context during successful prompt-end restoration, the helper suppresses the documented stale-extension-context rejection because the prompt was already accepted and late rethrow would surface a false orchestration failure. Falls back to `sendUserMessage(...)` only for non-replacement flows or runtimes that do not expose `sendMessage`. Runtime is O(n) in prompt length. Side effects are limited to hidden prompt delivery plus on-screen summary display.
914
914
  * @param[in] pi {ExtensionAPI} Handler-scoped extension API instance retained as the fallback dispatcher.
915
915
  * @param[in] content {string} Rendered prompt markdown.
916
916
  * @param[in] summary {string} Command invocation summary text displayed on screen.
@@ -942,9 +942,16 @@ function deliverPromptCommand(
942
942
  display: false,
943
943
  };
944
944
  if (typeof replacementContext?.sendMessage === "function") {
945
- replacementContext.sendMessage(summaryMessage);
946
- return Promise.resolve(
947
- replacementContext.sendMessage(hiddenPromptMessage, { triggerTurn: true }),
945
+ // The on-screen summary is non-critical; await it and suppress its delivery failures so a
946
+ // rejecting summary never surfaces as an unhandled rejection that could terminate the
947
+ // process before the authoritative hidden prompt turn drives prompt-end closure.
948
+ const summaryDelivery = Promise.resolve(
949
+ replacementContext.sendMessage(summaryMessage),
950
+ ).catch(() => {
951
+ // Intentionally suppressed: the hidden prompt message below is the authoritative turn trigger.
952
+ });
953
+ return summaryDelivery.then(() =>
954
+ replacementContext!.sendMessage!(hiddenPromptMessage, { triggerTurn: true }),
948
955
  ).catch((error) => {
949
956
  if (isStaleExtensionContextError(error)) {
950
957
  return;
@@ -953,9 +960,17 @@ function deliverPromptCommand(
953
960
  });
954
961
  }
955
962
  if (typeof pi.sendMessage === "function") {
956
- pi.sendMessage(summaryMessage);
957
- pi.sendMessage(hiddenPromptMessage, { triggerTurn: true });
958
- return Promise.resolve();
963
+ // `pi.sendMessage` is a fire-and-forget dispatcher in the pi runtime; chain both deliveries
964
+ // through one suppressed summary promise so a rejecting summary cannot terminate the process
965
+ // before the hidden prompt turn drives prompt-end closure.
966
+ const fallbackSummary = Promise.resolve(
967
+ pi.sendMessage(summaryMessage),
968
+ ).catch(() => {
969
+ // Intentionally suppressed: the hidden prompt message below is the authoritative turn trigger.
970
+ });
971
+ return fallbackSummary.then(() => {
972
+ pi.sendMessage(hiddenPromptMessage, { triggerTurn: true });
973
+ });
959
974
  }
960
975
  if (typeof replacementContext?.sendUserMessage === "function") {
961
976
  return Promise.resolve(replacementContext.sendUserMessage(content)).catch((error) => {
@@ -1072,7 +1087,7 @@ function logPromptWorkflowEvent(
1072
1087
  * @param[in] projectBase {string} Absolute original project base path.
1073
1088
  * @param[in] config {UseReqConfig} Effective project configuration.
1074
1089
  * @param[in] promptName {import("./core/prompt-command-catalog.js").PromptCommandName} Bundled prompt name.
1075
- * @param[in] nextState {DebugWorkflowState} Next workflow state.
1090
+ * @param[in] nextState {import("./core/extension-status.js").PiUsereqWorkflowState} Next workflow state.
1076
1091
  * @return {void} No return value.
1077
1092
  */
1078
1093
  function transitionPromptWorkflowState(
@@ -1081,7 +1096,7 @@ function transitionPromptWorkflowState(
1081
1096
  projectBase: string,
1082
1097
  config: UseReqConfig,
1083
1098
  promptName: import("./core/prompt-command-catalog.js").PromptCommandName,
1084
- nextState: DebugWorkflowState,
1099
+ nextState: import("./core/extension-status.js").PiUsereqWorkflowState,
1085
1100
  ): void {
1086
1101
  const previousState = statusController.state.workflowState;
1087
1102
  setPiUsereqWorkflowState(statusController, nextState, ctx);
@@ -3780,6 +3780,296 @@ test("worktree-backed prompt commands use replacement-session callbacks for prom
3780
3780
  }
3781
3781
  });
3782
3782
 
3783
+ /**
3784
+ * @brief Verifies worktree-backed prompt closure still merges and deletes the worktree when prompt delivery uses the production `sendMessage` custom-message path.
3785
+ * @details Simulates the pi runtime `sendMessage(message, { triggerTurn: true })` contract documented for `ReplacedSessionContext`: a `triggerTurn` custom message runs the agent loop which emits `agent_start` then `agent_end` (without a separate `before_agent_start`), and the `sendMessage` promise resolves only after the agent turn completes. The test proves that when the replacement session exposes `sendMessage`, prompt delivery must still drive the prompt-end closure so the worktree branch is merged into `base-path`, the worktree plus branch are deleted, and the workflow state returns to `idle`. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3786
+ * @return {Promise<void>} Promise resolved after `sendMessage`-driven prompt delivery, merge handling, and restored-base assertions complete.
3787
+ * @throws {AssertionError} Throws when `sendMessage`-driven prompt delivery leaves the worktree unmerged, the worktree or branch retained, or the workflow state not `idle`.
3788
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, DES-016
3789
+ */
3790
+ test("worktree-backed prompt commands merge and delete the worktree when prompt delivery uses sendMessage triggerTurn", async () => {
3791
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3792
+ const previousCwd = process.cwd();
3793
+ try {
3794
+ const pi = createFakePi();
3795
+ piUsereqExtension(pi);
3796
+ const ctx = createFakeCtx(projectBase);
3797
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3798
+
3799
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3800
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3801
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3802
+ let activeSessionCtx: any = ctx;
3803
+ ctx.switchSession = async (
3804
+ sessionPath: string,
3805
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3806
+ ) => {
3807
+ const result = await originalSwitchSession(sessionPath);
3808
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
3809
+ const replacementCtx = {
3810
+ ...ctx,
3811
+ cwd: replacementCwd,
3812
+ sessionManager: {
3813
+ ...ctx.sessionManager,
3814
+ getCwd: () => replacementCwd,
3815
+ getSessionFile: () => sessionPath,
3816
+ },
3817
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3818
+ pi.sentUserMessages.push({ content, options: sendOptions });
3819
+ },
3820
+ async sendMessage(message: unknown, sendOptions?: unknown) {
3821
+ sentMessages.push({ message, options: sendOptions });
3822
+ if ((sendOptions as { triggerTurn?: boolean } | undefined)?.triggerTurn === true) {
3823
+ await Promise.resolve();
3824
+ await pi.emit("agent_start", {}, activeSessionCtx);
3825
+ await pi.emit("agent_end", {
3826
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3827
+ }, activeSessionCtx);
3828
+ }
3829
+ },
3830
+ };
3831
+ activeSessionCtx = replacementCtx;
3832
+ if (options?.withSession) {
3833
+ process.chdir(replacementCwd);
3834
+ await options.withSession(replacementCtx);
3835
+ ctx.cwd = projectBase;
3836
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3837
+ ctx.sessionManager.getCwd = () => projectBase;
3838
+ return result;
3839
+ }
3840
+ ctx.cwd = projectBase;
3841
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3842
+ ctx.sessionManager.getCwd = () => projectBase;
3843
+ process.chdir(projectBase);
3844
+ return result;
3845
+ };
3846
+
3847
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3848
+
3849
+ assert.ok(
3850
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
3851
+ "prompt delivery must use sendMessage with triggerTurn",
3852
+ );
3853
+ const promptMessage = sentMessages.find((entry) =>
3854
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
3855
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
3856
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
3857
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3858
+ assert.ok(worktreeMatch, promptText);
3859
+ const worktreeName = worktreeMatch?.[1] ?? "";
3860
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3861
+
3862
+ assert.equal(process.cwd(), projectBase);
3863
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
3864
+ assert.notEqual(
3865
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3866
+ cwd: projectBase,
3867
+ encoding: "utf8",
3868
+ }).status,
3869
+ 0,
3870
+ "worktree branch must be deleted after successful closure",
3871
+ );
3872
+ const logResult = spawnSync("git", ["log", "--oneline", "-1"], { cwd: projectBase, encoding: "utf8" });
3873
+ assert.match(logResult.stdout, /init/, "base-path must remain on the original head after a no-commit ff-only merge");
3874
+ } finally {
3875
+ process.chdir(previousCwd);
3876
+ fs.rmSync(projectBase, { recursive: true, force: true });
3877
+ }
3878
+ });
3879
+
3880
+ /**
3881
+ * @brief Verifies a rejecting on-screen summary delivery never surfaces as an unhandled rejection and never interrupts the worktree merge.
3882
+ * @details Simulates the pi runtime `sendMessage` contract where the non-critical `display:true` summary delivery rejects (for example because the session is torn down mid-emit during prompt-end closure) while the authoritative `display:false` hidden prompt message with `triggerTurn:true` still drives the agent turn. The test proves `deliverPromptCommand` must await and suppress summary delivery failures so the rejection cannot terminate the process (Node default `--unhandled-rejections=throw`) before prompt-end closure runs the worktree merge, branch deletion, and worktree deletion. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3883
+ * @return {Promise<void>} Promise resolved after summary-rejection-tolerant prompt delivery, merge handling, and restored-base assertions complete.
3884
+ * @throws {AssertionError} Throws when a rejecting summary surfaces as an unhandled rejection or leaves the worktree unmerged.
3885
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, REQ-334, DES-016
3886
+ */
3887
+ test("worktree-backed prompt commands tolerate a rejecting summary sendMessage without interrupting the merge", async () => {
3888
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3889
+ const previousCwd = process.cwd();
3890
+ let unhandledRejections: unknown[] = [];
3891
+ const rejectionHandler = (reason: unknown): void => { unhandledRejections.push(reason); };
3892
+ process.on("unhandledRejection", rejectionHandler);
3893
+ try {
3894
+ const pi = createFakePi();
3895
+ piUsereqExtension(pi);
3896
+ const ctx = createFakeCtx(projectBase);
3897
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3898
+
3899
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3900
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3901
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3902
+ let activeSessionCtx: any = ctx;
3903
+ ctx.switchSession = async (
3904
+ sessionPath: string,
3905
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3906
+ ) => {
3907
+ const result = await originalSwitchSession(sessionPath);
3908
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
3909
+ const replacementCtx = {
3910
+ ...ctx,
3911
+ cwd: replacementCwd,
3912
+ sessionManager: {
3913
+ ...ctx.sessionManager,
3914
+ getCwd: () => replacementCwd,
3915
+ getSessionFile: () => sessionPath,
3916
+ },
3917
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3918
+ pi.sentUserMessages.push({ content, options: sendOptions });
3919
+ },
3920
+ async sendMessage(message: unknown, sendOptions?: unknown) {
3921
+ const isTrigger = (sendOptions as { triggerTurn?: boolean } | undefined)?.triggerTurn === true;
3922
+ sentMessages.push({ message, options: sendOptions });
3923
+ if (!isTrigger) {
3924
+ throw new Error("summary sendMessage rejected");
3925
+ }
3926
+ await Promise.resolve();
3927
+ await pi.emit("agent_start", {}, activeSessionCtx);
3928
+ await pi.emit("agent_end", {
3929
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3930
+ }, activeSessionCtx);
3931
+ },
3932
+ };
3933
+ activeSessionCtx = replacementCtx;
3934
+ if (options?.withSession) {
3935
+ process.chdir(replacementCwd);
3936
+ await options.withSession(replacementCtx);
3937
+ ctx.cwd = projectBase;
3938
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3939
+ ctx.sessionManager.getCwd = () => projectBase;
3940
+ return result;
3941
+ }
3942
+ ctx.cwd = projectBase;
3943
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3944
+ ctx.sessionManager.getCwd = () => projectBase;
3945
+ process.chdir(projectBase);
3946
+ return result;
3947
+ };
3948
+
3949
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3950
+ await new Promise((resolve) => setImmediate(resolve));
3951
+
3952
+ assert.ok(
3953
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
3954
+ "prompt delivery must still dispatch the hidden prompt message with triggerTurn",
3955
+ );
3956
+ assert.equal(process.cwd(), projectBase, "base-path must be restored after successful closure");
3957
+ assert.equal(unhandledRejections.length, 0, "rejecting summary delivery must not surface as an unhandled rejection");
3958
+ const promptMessage = sentMessages.find((entry) =>
3959
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
3960
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
3961
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
3962
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3963
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3964
+ assert.ok(worktreeMatch, promptText);
3965
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
3966
+ } finally {
3967
+ process.off("unhandledRejection", rejectionHandler);
3968
+ process.chdir(previousCwd);
3969
+ fs.rmSync(projectBase, { recursive: true, force: true });
3970
+ }
3971
+ });
3972
+
3973
+ /**
3974
+ * @brief Verifies worktree-backed prompt closure still merges and deletes the worktree when prompt delivery falls back to `pi.sendMessage` because the replacement session omits `sendMessage`.
3975
+ * @details Simulates the pi runtime where `ctx.switchSession(...)` exposes a replacement context without `sendMessage` while the top-level `pi.sendMessage` is available as a fire-and-forget custom-message dispatcher. The test proves that even in this fallback path, prompt delivery must drive the prompt-end closure so the worktree branch is merged into `base-path`, the worktree plus branch are deleted, and the workflow state returns to `idle`. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3976
+ * @return {Promise<void>} Promise resolved after `sendMessage`-fallback prompt delivery, merge handling, and restored-base assertions complete.
3977
+ * @throws {AssertionError} Throws when `pi.sendMessage`-fallback prompt delivery leaves the worktree unmerged, the worktree or branch retained, or the workflow state not `idle`.
3978
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, DES-016
3979
+ */
3980
+ test("worktree-backed prompt commands merge and delete the worktree when prompt delivery falls back to pi.sendMessage", async () => {
3981
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3982
+ const previousCwd = process.cwd();
3983
+ try {
3984
+ const pi = createFakePi();
3985
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3986
+ (pi as any).sendMessage = (message: unknown, options?: unknown) => {
3987
+ sentMessages.push({ message, options });
3988
+ // Simulate the real SDK fire-and-forget dispatcher: trigger the agent turn asynchronously.
3989
+ if ((options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true) {
3990
+ Promise.resolve().then(async () => {
3991
+ await pi.emit("agent_start", {}, activeSessionCtx);
3992
+ await pi.emit("agent_end", {
3993
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3994
+ }, activeSessionCtx);
3995
+ });
3996
+ }
3997
+ };
3998
+ piUsereqExtension(pi);
3999
+ const ctx = createFakeCtx(projectBase);
4000
+ await pi.emit("session_start", { reason: "startup" }, ctx);
4001
+
4002
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
4003
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
4004
+ let activeSessionCtx: any = ctx;
4005
+ ctx.switchSession = async (
4006
+ sessionPath: string,
4007
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
4008
+ ) => {
4009
+ const result = await originalSwitchSession(sessionPath);
4010
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
4011
+ const replacementCtx = {
4012
+ ...ctx,
4013
+ cwd: replacementCwd,
4014
+ sessionManager: {
4015
+ ...ctx.sessionManager,
4016
+ getCwd: () => replacementCwd,
4017
+ getSessionFile: () => sessionPath,
4018
+ },
4019
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
4020
+ pi.sentUserMessages.push({ content, options: sendOptions });
4021
+ },
4022
+ };
4023
+ activeSessionCtx = replacementCtx;
4024
+ if (options?.withSession) {
4025
+ process.chdir(replacementCwd);
4026
+ await options.withSession(replacementCtx);
4027
+ ctx.cwd = projectBase;
4028
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
4029
+ ctx.sessionManager.getCwd = () => projectBase;
4030
+ return result;
4031
+ }
4032
+ ctx.cwd = projectBase;
4033
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
4034
+ ctx.sessionManager.getCwd = () => projectBase;
4035
+ process.chdir(projectBase);
4036
+ return result;
4037
+ };
4038
+
4039
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
4040
+ // The real SDK `pi.sendMessage` dispatcher is fire-and-forget; let the simulated agent turn drain.
4041
+ await new Promise((resolve) => setImmediate(resolve));
4042
+ await new Promise((resolve) => setImmediate(resolve));
4043
+
4044
+ assert.ok(
4045
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
4046
+ "prompt delivery must use pi.sendMessage with triggerTurn",
4047
+ );
4048
+ const promptMessage = sentMessages.find((entry) =>
4049
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
4050
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
4051
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
4052
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
4053
+ assert.ok(worktreeMatch, promptText);
4054
+ const worktreeName = worktreeMatch?.[1] ?? "";
4055
+ const executionBasePath = worktreeMatch?.[2] ?? "";
4056
+
4057
+ assert.equal(process.cwd(), projectBase);
4058
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
4059
+ assert.notEqual(
4060
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
4061
+ cwd: projectBase,
4062
+ encoding: "utf8",
4063
+ }).status,
4064
+ 0,
4065
+ "worktree branch must be deleted after successful closure",
4066
+ );
4067
+ } finally {
4068
+ process.chdir(previousCwd);
4069
+ fs.rmSync(projectBase, { recursive: true, force: true });
4070
+ }
4071
+ });
4072
+
3783
4073
  /**
3784
4074
  * @brief Verifies worktree-backed prompt orchestration against the pi runtime session-switch contract.
3785
4075
  * @details Simulates the current pi runtime behavior discovered in `/tmp/pi-mono`: `ctx.switchSession(...)` exposes the replacement session through `withSession(...)` and replacement-session contexts while the host process cwd stays anchored to the original project directory until the extension updates it explicitly. The test proves prompt delivery and prompt-end closure must synchronize `process.cwd()` with the active worktree session during execution and restore `base-path` plus remove the worktree after successful merge even when the pi host leaves cwd unchanged. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.