pi-usereq 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/pi-usereq/docs/REFERENCES.md +818 -691
  5. package/pi-usereq/docs/REQUIREMENTS.md +131 -77
  6. package/pi-usereq/docs/WORKFLOW.md +185 -51
  7. package/scripts/lib/extension-debug-harness.ts +2 -2
  8. package/scripts/tool-args-to-params.ts +2 -2
  9. package/src/cli.ts +12 -12
  10. package/src/core/extension-status.ts +69 -12
  11. package/src/core/pi-notify.ts +5 -5
  12. package/src/core/pi-usereq-tools.ts +4 -2
  13. package/src/core/prompt-command-catalog.ts +4 -5
  14. package/src/core/prompt-command-runtime.ts +183 -44
  15. package/src/core/prompts.ts +0 -2
  16. package/src/core/req-references-command.ts +175 -0
  17. package/src/core/req-reset-command.ts +323 -0
  18. package/src/core/resources.ts +6 -23
  19. package/src/core/settings-menu.ts +85 -28
  20. package/src/core/tool-runner.ts +26 -6
  21. package/src/index.ts +523 -85
  22. package/tests/attended-results-scenarios.ts +5 -5
  23. package/tests/cli-command-option-parity.test.ts +25 -25
  24. package/tests/debug-extension-harness.test.ts +1 -1
  25. package/tests/extension-registration.test.ts +1029 -82
  26. package/tests/oracle-project.test.ts +4 -4
  27. package/tests/oracle-standalone.test.ts +5 -5
  28. package/src/core/reference-payload.ts +0 -752
  29. package/src/resources/prompts/references.md +0 -64
  30. /package/tests/fixtures_attended_results/project/{references.json → summarize.json} +0 -0
  31. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_c.c.json +0 -0
  32. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_cpp.cpp.json +0 -0
  33. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_csharp.cs.json +0 -0
  34. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_elixir.ex.json +0 -0
  35. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_go.go.json +0 -0
  36. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_haskell.hs.json +0 -0
  37. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_java.java.json +0 -0
  38. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_javascript.js.json +0 -0
  39. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_kotlin.kt.json +0 -0
  40. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_lua.lua.json +0 -0
  41. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_perl.pl.json +0 -0
  42. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_php.php.json +0 -0
  43. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_python.py.json +0 -0
  44. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_ruby.rb.json +0 -0
  45. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_rust.rs.json +0 -0
  46. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_scala.scala.json +0 -0
  47. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_shell.sh.json +0 -0
  48. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_swift.swift.json +0 -0
  49. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_typescript.ts.json +0 -0
  50. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_zig.zig.json +0 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @file
3
+ * @brief Implements the specialized `req-reset` slash-command workflow.
4
+ * @details Performs non-agentic prompt-orchestration recovery by preserving the current execution-session transcript when available, restoring the original session-backed `base-path`, force-removing every generated sibling worktree and matching branch, and returning deterministic cleanup facts to the extension command handler. Runtime is dominated by session switching plus git subprocess execution. Side effects include session-file reads and writes, active-session replacement, host-process cwd mutation, worktree deletion, branch deletion, and filesystem removal.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import {
11
+ normalizeGitWorktreePrefix,
12
+ type UseReqConfig,
13
+ } from "./config.js";
14
+ import { ReqError } from "./errors.js";
15
+ import {
16
+ deletePromptWorktree,
17
+ getPromptCommandErrorContext,
18
+ preservePromptCommandExecutionTranscript,
19
+ restorePromptCommandExecution,
20
+ type PromptCommandExecutionPlan,
21
+ } from "./prompt-command-runtime.js";
22
+ import { resolveRuntimeGitPath } from "./runtime-project-paths.js";
23
+
24
+ /**
25
+ * @brief Declares the fixed slash-command description for `req-reset`.
26
+ * @details Preserves a deterministic human-facing label for the dedicated non-agentic recovery command while keeping the implementation independent from bundled prompt Markdown files. Access complexity is O(1).
27
+ */
28
+ export const REQ_RESET_COMMAND_DESCRIPTION = "Reset req workflow state, restore base-path, and remove generated worktrees";
29
+
30
+ /**
31
+ * @brief Describes the prepared execution facts for one `req-reset` run.
32
+ * @details Stores the validated project base, resolved git root, sibling-worktree parent directory, generated-name matcher, and optional persisted prompt execution plan used for transcript preservation plus base-path restoration. The interface is compile-time only and introduces no runtime cost.
33
+ */
34
+ export interface ReqResetCommandPlan {
35
+ basePath: string;
36
+ gitPath: string;
37
+ parentPath: string;
38
+ worktreeNamePattern: RegExp;
39
+ promptRequest: PromptCommandExecutionPlan | undefined;
40
+ }
41
+
42
+ /**
43
+ * @brief Describes the outcome of one `req-reset` execution attempt.
44
+ * @details Captures the last valid session-bound context, transcript-preservation and base-path-restoration facts, removed generated worktree and branch names, and one aggregated failure string when any recovery step fails. The interface is compile-time only and introduces no runtime cost.
45
+ */
46
+ export interface ReqResetCommandExecutionResult {
47
+ activeContext: ReqResetCommandContext | undefined;
48
+ transcriptPreserved: boolean;
49
+ restoredBasePath: boolean;
50
+ removedWorktreeDirs: string[];
51
+ removedBranchNames: string[];
52
+ errorMessage?: string;
53
+ }
54
+
55
+ /**
56
+ * @brief Describes the session-bound context surface reused during `req-reset` recovery.
57
+ * @details Reuses the session-switching contract already accepted by `restorePromptCommandExecution(...)` so the dedicated reset command can restore the original session without depending on concrete pi runtime classes. The alias is compile-time only and introduces no runtime cost.
58
+ */
59
+ type ReqResetCommandContext = Parameters<typeof restorePromptCommandExecution>[1];
60
+
61
+ /**
62
+ * @brief Executes one synchronous subprocess and captures UTF-8 output.
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
+ * @param[in] command {string[]} Executable plus argument vector.
65
+ * @param[in] cwd {string} Working directory for the subprocess.
66
+ * @return {ReturnType<typeof spawnSync>} Captured subprocess result.
67
+ */
68
+ function runCapture(command: string[], cwd: string): ReturnType<typeof spawnSync> {
69
+ return spawnSync(command[0]!, command.slice(1), {
70
+ cwd,
71
+ encoding: "utf8",
72
+ });
73
+ }
74
+
75
+ /**
76
+ * @brief Escapes one literal string for safe JavaScript regular-expression reuse.
77
+ * @details Prefixes every regular-expression metacharacter with `\\` so generated worktree-name patterns can embed persisted prefixes and repository basenames without introducing unintended matcher semantics. Runtime is O(n) in string length. No external state is mutated.
78
+ * @param[in] text {string} Literal text fragment.
79
+ * @return {string} Regular-expression-safe literal fragment.
80
+ */
81
+ function escapeReqResetRegExpLiteral(text: string): string {
82
+ return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
83
+ }
84
+
85
+ /**
86
+ * @brief Builds the generated-worktree name matcher used by `req-reset` cleanup.
87
+ * @details Reuses the configured worktree prefix plus repository basename, accepts any sanitized branch token between those fixed segments and the final execution identifier, and constrains the timestamp suffix to the documented `YYYYMMDDHHMMSS` shape. Runtime is O(p) in combined prefix and project-name length. No external state is mutated.
88
+ * @param[in] gitRoot {string} Absolute runtime git root.
89
+ * @param[in] config {UseReqConfig} Effective project configuration.
90
+ * @return {RegExp} Matcher for generated prompt-command worktree and branch names.
91
+ * @satisfies REQ-309, REQ-310, REQ-311
92
+ */
93
+ function buildReqResetWorktreeNamePattern(gitRoot: string, config: UseReqConfig): RegExp {
94
+ const worktreePrefix = normalizeGitWorktreePrefix(config.GIT_WORKTREE_PREFIX);
95
+ const projectName = path.basename(gitRoot);
96
+ return new RegExp(
97
+ `^${escapeReqResetRegExpLiteral(worktreePrefix)}${escapeReqResetRegExpLiteral(projectName)}-.+-\\d{14}$`,
98
+ "u",
99
+ );
100
+ }
101
+
102
+ /**
103
+ * @brief Lists every registered git worktree root for one repository.
104
+ * @details Executes `git worktree list --porcelain`, extracts each `worktree <path>` record, resolves every listed path to an absolute form, and returns the ordered list used by generated-worktree cleanup. Runtime is dominated by one git subprocess plus O(n) parsing in listed worktree count. Side effects include subprocess creation.
105
+ * @param[in] gitRoot {string} Absolute runtime git root.
106
+ * @return {string[]} Absolute registered worktree-root paths.
107
+ * @throws {ReqError} Throws when git worktree enumeration fails.
108
+ */
109
+ function listReqResetRegisteredWorktreeRoots(gitRoot: string): string[] {
110
+ const listResult = runCapture(["git", "worktree", "list", "--porcelain"], gitRoot);
111
+ if (listResult.error || listResult.status !== 0) {
112
+ throw new ReqError("ERROR: Unable to enumerate git worktrees for req-reset.", 1);
113
+ }
114
+ return listResult.stdout
115
+ .split(/\r?\n/u)
116
+ .filter((line) => line.startsWith("worktree "))
117
+ .map((line) => path.resolve(line.slice("worktree ".length)));
118
+ }
119
+
120
+ /**
121
+ * @brief Lists sibling directories whose names match the generated-worktree contract.
122
+ * @details Reads the repository parent directory, keeps only direct child directories whose basenames match the supplied generated-name pattern, and resolves each candidate to an absolute path so `req-reset` can remove unregistered leftover directories as well as registered git worktrees. Runtime is dominated by directory enumeration plus O(n) matcher cost. Side effects are limited to filesystem reads.
123
+ * @param[in] parentPath {string} Absolute directory containing sibling worktree roots.
124
+ * @param[in] worktreeNamePattern {RegExp} Generated-worktree name matcher.
125
+ * @return {string[]} Absolute sibling directory paths whose basenames match the generated-name contract.
126
+ * @throws {ReqError} Throws when directory enumeration fails.
127
+ */
128
+ function listReqResetSiblingWorktreeRoots(
129
+ parentPath: string,
130
+ worktreeNamePattern: RegExp,
131
+ ): string[] {
132
+ const normalizedParentPath = path.resolve(parentPath);
133
+ let siblingEntries: fs.Dirent[];
134
+ try {
135
+ siblingEntries = fs.readdirSync(normalizedParentPath, { withFileTypes: true });
136
+ } catch (error) {
137
+ const errorMessage = error instanceof Error ? error.message : String(error);
138
+ throw new ReqError(
139
+ `ERROR: Unable to inspect sibling worktrees in ${normalizedParentPath}: ${errorMessage}.`,
140
+ 1,
141
+ );
142
+ }
143
+ return siblingEntries
144
+ .filter((entry) => entry.isDirectory() && worktreeNamePattern.test(entry.name))
145
+ .map((entry) => path.join(normalizedParentPath, entry.name));
146
+ }
147
+
148
+ /**
149
+ * @brief Lists every generated sibling worktree candidate targeted by `req-reset`.
150
+ * @details Unions registered git-worktree roots with matching sibling directories so cleanup covers both registered worktrees and unregistered leftover directories, then sorts the canonical absolute paths for deterministic deletion order. Runtime is dominated by git worktree enumeration plus sibling-directory scanning. Side effects are limited to subprocess creation and filesystem reads.
151
+ * @param[in] parentPath {string} Absolute directory containing sibling worktree roots.
152
+ * @param[in] gitRoot {string} Absolute runtime git root.
153
+ * @param[in] worktreeNamePattern {RegExp} Generated-worktree name matcher.
154
+ * @return {string[]} Sorted absolute worktree-root paths targeted for deletion.
155
+ * @throws {ReqError} Throws when git worktree or sibling-directory enumeration fails.
156
+ */
157
+ function listReqResetMatchingWorktreeRoots(
158
+ parentPath: string,
159
+ gitRoot: string,
160
+ worktreeNamePattern: RegExp,
161
+ ): string[] {
162
+ const matchingRoots = new Set<string>();
163
+ for (const worktreeRootPath of listReqResetRegisteredWorktreeRoots(gitRoot)) {
164
+ if (worktreeNamePattern.test(path.basename(worktreeRootPath))) {
165
+ matchingRoots.add(path.resolve(worktreeRootPath));
166
+ }
167
+ }
168
+ for (const siblingRootPath of listReqResetSiblingWorktreeRoots(parentPath, worktreeNamePattern)) {
169
+ matchingRoots.add(path.resolve(siblingRootPath));
170
+ }
171
+ return [...matchingRoots].sort((left, right) => left.localeCompare(right));
172
+ }
173
+
174
+ /**
175
+ * @brief Lists every matching generated branch targeted by `req-reset`.
176
+ * @details Executes `git branch --list --format=%(refname:short)`, filters the local branch inventory through the generated-name matcher, and returns a sorted list so later forced branch deletion remains deterministic. Runtime is dominated by one git subprocess plus O(n) parsing in listed branch count. Side effects include subprocess creation.
177
+ * @param[in] gitRoot {string} Absolute runtime git root.
178
+ * @param[in] worktreeNamePattern {RegExp} Generated-worktree name matcher.
179
+ * @return {string[]} Sorted local branch names targeted for deletion.
180
+ * @throws {ReqError} Throws when local branch enumeration fails.
181
+ */
182
+ function listReqResetMatchingBranchNames(
183
+ gitRoot: string,
184
+ worktreeNamePattern: RegExp,
185
+ ): string[] {
186
+ const branchResult = runCapture(["git", "branch", "--list", "--format=%(refname:short)"], gitRoot);
187
+ if (branchResult.error || branchResult.status !== 0) {
188
+ throw new ReqError("ERROR: Unable to enumerate git branches for req-reset.", 1);
189
+ }
190
+ return branchResult.stdout
191
+ .split(/\r?\n/u)
192
+ .map((line) => line.trim())
193
+ .filter((line) => line !== "" && worktreeNamePattern.test(line))
194
+ .sort((left, right) => left.localeCompare(right));
195
+ }
196
+
197
+ /**
198
+ * @brief Prepares the specialized `req-reset` execution plan.
199
+ * @details Resolves the active project base into a runtime git root, derives the sibling-worktree parent directory and generated-name matcher from the same prefix plus repository-basename contract used by prompt-command worktree generation, and keeps only worktree-backed persisted prompt execution plans for transcript-preserving base-path restoration. Runtime is O(p) in path length. No external state is mutated.
200
+ * @param[in] projectBase {string} Absolute project base path.
201
+ * @param[in] config {UseReqConfig} Effective project configuration.
202
+ * @param[in] promptRequest {PromptCommandExecutionPlan | undefined} Pending or active prompt execution plan when available.
203
+ * @return {ReqResetCommandPlan} Prepared recovery and cleanup plan.
204
+ * @throws {ReqError} Throws when the repository root cannot be resolved.
205
+ * @satisfies REQ-306, REQ-309, REQ-310, REQ-311
206
+ */
207
+ export function prepareReqResetCommandExecution(
208
+ projectBase: string,
209
+ config: UseReqConfig,
210
+ promptRequest?: PromptCommandExecutionPlan,
211
+ ): ReqResetCommandPlan {
212
+ const basePath = path.resolve(projectBase);
213
+ const gitPath = resolveRuntimeGitPath(basePath);
214
+ if (!gitPath) {
215
+ throw new ReqError("ERROR: Unable to resolve git repository for req-reset.", 1);
216
+ }
217
+ const normalizedGitPath = path.resolve(gitPath);
218
+ const resetPromptRequest = promptRequest?.worktreeDir
219
+ && promptRequest.worktreeRootPath
220
+ && promptRequest.worktreePath
221
+ ? promptRequest
222
+ : undefined;
223
+ return {
224
+ basePath,
225
+ gitPath: normalizedGitPath,
226
+ parentPath: path.resolve(normalizedGitPath, ".."),
227
+ worktreeNamePattern: buildReqResetWorktreeNamePattern(normalizedGitPath, config),
228
+ promptRequest: resetPromptRequest,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * @brief Executes the specialized `req-reset` recovery and cleanup workflow.
234
+ * @details Preserves the execution-session transcript into the original session file when a worktree-backed prompt execution plan is still available, restores the original session-backed `base-path` through the shared prompt-command restoration helper, force-removes every matching sibling worktree directory, force-removes every remaining matching local branch, and aggregates any failure diagnostics without rolling back successful cleanup steps. Runtime is dominated by session switching plus git subprocess execution. Side effects include session-file reads and writes, active-session replacement, host-process cwd mutation, worktree deletion, branch deletion, and filesystem reads.
235
+ * @param[in] plan {ReqResetCommandPlan} Prepared recovery and cleanup plan.
236
+ * @param[in] ctx {ReqResetCommandContext | undefined} Optional session-bound command context.
237
+ * @return {Promise<ReqResetCommandExecutionResult>} Recovery and cleanup outcome facts.
238
+ * @satisfies REQ-305, REQ-307, REQ-308, REQ-309, REQ-310, REQ-313
239
+ */
240
+ export async function executeReqResetCommandExecution(
241
+ plan: ReqResetCommandPlan,
242
+ ctx?: ReqResetCommandContext,
243
+ ): Promise<ReqResetCommandExecutionResult> {
244
+ let activeContext = ctx;
245
+ let transcriptPreserved = plan.promptRequest === undefined;
246
+ let restoredBasePath = plan.promptRequest === undefined;
247
+ const removedWorktreeDirs: string[] = [];
248
+ const removedBranchNames: string[] = [];
249
+ const errorMessages: string[] = [];
250
+
251
+ if (plan.promptRequest !== undefined) {
252
+ try {
253
+ preservePromptCommandExecutionTranscript(plan.promptRequest);
254
+ transcriptPreserved = true;
255
+ } catch (error) {
256
+ transcriptPreserved = false;
257
+ errorMessages.push(error instanceof Error ? error.message : String(error));
258
+ }
259
+ try {
260
+ activeContext = await restorePromptCommandExecution(plan.promptRequest, activeContext);
261
+ restoredBasePath = true;
262
+ } catch (error) {
263
+ return {
264
+ activeContext: (getPromptCommandErrorContext(error) ?? activeContext) as ReqResetCommandContext | undefined,
265
+ transcriptPreserved,
266
+ restoredBasePath: false,
267
+ removedWorktreeDirs,
268
+ removedBranchNames,
269
+ errorMessage: error instanceof Error ? error.message : String(error),
270
+ };
271
+ }
272
+ }
273
+
274
+ let matchingWorktreeRoots: string[] = [];
275
+ try {
276
+ matchingWorktreeRoots = listReqResetMatchingWorktreeRoots(
277
+ plan.parentPath,
278
+ plan.gitPath,
279
+ plan.worktreeNamePattern,
280
+ );
281
+ } catch (error) {
282
+ errorMessages.push(error instanceof Error ? error.message : String(error));
283
+ }
284
+
285
+ for (const worktreeRootPath of matchingWorktreeRoots) {
286
+ const worktreeDir = path.basename(worktreeRootPath);
287
+ try {
288
+ deletePromptWorktree(plan.basePath, worktreeDir, worktreeRootPath);
289
+ removedWorktreeDirs.push(worktreeDir);
290
+ } catch (error) {
291
+ errorMessages.push(error instanceof Error ? error.message : String(error));
292
+ }
293
+ }
294
+
295
+ try {
296
+ const matchingBranchNames = listReqResetMatchingBranchNames(plan.gitPath, plan.worktreeNamePattern);
297
+ for (const branchName of matchingBranchNames) {
298
+ const deleteResult = runCapture(["git", "branch", "-D", branchName], plan.gitPath);
299
+ if (deleteResult.error || deleteResult.status !== 0) {
300
+ const diagnostic = deleteResult.stderr.trim()
301
+ || deleteResult.stdout.trim()
302
+ || deleteResult.error?.message
303
+ || `Unable to remove branch ${branchName}.`;
304
+ errorMessages.push(`ERROR: git branch -D failed for ${branchName}: ${diagnostic}`);
305
+ continue;
306
+ }
307
+ removedBranchNames.push(branchName);
308
+ }
309
+ } catch (error) {
310
+ errorMessages.push(error instanceof Error ? error.message : String(error));
311
+ }
312
+
313
+ return {
314
+ activeContext,
315
+ transcriptPreserved,
316
+ restoredBasePath,
317
+ removedWorktreeDirs,
318
+ removedBranchNames,
319
+ errorMessage: errorMessages.length > 0
320
+ ? errorMessages.join(" ")
321
+ : undefined,
322
+ };
323
+ }
@@ -65,33 +65,16 @@ export function readBundledPrompt(promptName: string): string {
65
65
  }
66
66
 
67
67
  /**
68
- * @brief Extracts the YAML-front-matter `description` field from one bundled prompt.
69
- * @details Parses only the leading front-matter block, resolves the first scalar `description` entry, strips one matching pair of wrapping quotes, and unescapes quoted apostrophe or quote characters used in prompt metadata. Runtime is O(n) in prompt length. Side effects are limited to filesystem reads delegated through `readBundledPrompt(...)`.
68
+ * @brief Extracts the first Markdown level-one heading from one bundled prompt.
69
+ * @details Removes one optional leading YAML front-matter block, scans the remaining markdown body for the first line that begins with `# `, and returns the heading payload without the marker or surrounding whitespace. Runtime is O(n) in prompt length. Side effects are limited to filesystem reads delegated through `readBundledPrompt(...)`.
70
70
  * @param[in] promptName {string} Prompt identifier without the `.md` suffix.
71
- * @return {string} Normalized prompt description or the empty string when the front matter does not declare one.
71
+ * @return {string} First `# ` heading payload, or the empty string when no level-one heading exists.
72
72
  */
73
73
  export function readBundledPromptDescription(promptName: string): string {
74
74
  const promptText = readBundledPrompt(promptName);
75
- const frontMatterMatch = promptText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
76
- if (!frontMatterMatch) {
77
- return "";
78
- }
79
- const descriptionLine = frontMatterMatch[1]
80
- .split(/\r?\n/)
81
- .find((line) => line.startsWith("description:"));
82
- if (!descriptionLine) {
83
- return "";
84
- }
85
- const rawValue = descriptionLine.slice("description:".length).trim();
86
- const unquotedValue = rawValue.length >= 2
87
- && ((rawValue.startsWith('"') && rawValue.endsWith('"'))
88
- || (rawValue.startsWith("'") && rawValue.endsWith("'")))
89
- ? rawValue.slice(1, -1)
90
- : rawValue;
91
- return unquotedValue
92
- .replace(/\\"/g, '"')
93
- .replace(/\\'/g, "'")
94
- .trim();
75
+ const promptBody = promptText.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "");
76
+ const headingMatch = promptBody.match(/^# (.+?)\s*$/m);
77
+ return headingMatch?.[1]?.trim() ?? "";
95
78
  }
96
79
 
97
80
  /**
@@ -9,7 +9,7 @@ import { Container, SettingsList, Text, type Component, type SettingItem, type S
9
9
 
10
10
  /**
11
11
  * @brief Describes one selectable pi-usereq settings-menu choice.
12
- * @details Stores the stable action identifier, left-column label, optional label and value tone overrides, optional disabled state, right-column current value, and bottom-line description consumed by the shared settings-menu renderer. The interface is compile-time only and introduces no runtime cost.
12
+ * @details Stores the stable action identifier, left-column label, optional label and value tone overrides, optional disabled state, right-column current value, optional inline-cycle values, and bottom-line description consumed by the shared settings-menu renderer. The interface is compile-time only and introduces no runtime cost.
13
13
  */
14
14
  export interface PiUsereqSettingsMenuChoice {
15
15
  id: string;
@@ -18,6 +18,7 @@ export interface PiUsereqSettingsMenuChoice {
18
18
  value: string;
19
19
  valueTone?: "default" | "dim";
20
20
  disabled?: boolean;
21
+ values?: readonly string[];
21
22
  description: string;
22
23
  }
23
24
 
@@ -35,12 +36,12 @@ export interface PiUsereqSettingsMenuBridge {
35
36
 
36
37
  /**
37
38
  * @brief Describes optional behavior overrides for one settings-menu render.
38
- * @details Carries the caller-selected initial focus row so menu re-renders can
39
- * preserve selection after an in-place toggle or value edit. The interface is
40
- * compile-time only and introduces no runtime cost.
39
+ * @details Carries the caller-selected initial focus row, the optional dynamic choice supplier used to rebuild dependent rows after inline toggles, and the optional inline-change callback used to persist `SettingsList` value cycles without closing the menu. The interface is compile-time only and introduces no runtime cost.
41
40
  */
42
41
  export interface PiUsereqSettingsMenuOptions {
43
42
  initialSelectedId?: string;
43
+ getChoices?: () => PiUsereqSettingsMenuChoice[];
44
+ onChange?: (choiceId: string, newValue: string) => void;
44
45
  }
45
46
 
46
47
  /**
@@ -162,7 +163,7 @@ function createImmediateSelectionComponent(choiceId: string, done: (value?: stri
162
163
 
163
164
  /**
164
165
  * @brief Builds `SettingsList` items from one menu-choice vector.
165
- * @details Copies labels, current values, label-tone overrides, value-tone overrides, disabled-state semantics, and descriptions into `SettingItem` records and attaches a submenu that resolves the outer custom UI with the selected choice identifier only for enabled rows. Runtime is O(n) in choice count. No external state is mutated.
166
+ * @details Copies labels, current values, label-tone overrides, value-tone overrides, disabled-state semantics, inline-cycle values, and descriptions into `SettingItem` records. Non-disabled rows with `values` cycle inline on `Enter` or `Space`, while other non-disabled rows resolve the outer custom UI through the immediate submenu bridge. Runtime is O(n) in choice count. No external state is mutated.
166
167
  * @param[in] theme {PiUsereqSettingsTheme} Callback-local pi theme adapter.
167
168
  * @param[in] choices {PiUsereqSettingsMenuChoice[]} Ordered menu-choice vector.
168
169
  * @param[in] done {(value?: string) => void} Outer custom-UI completion callback.
@@ -182,19 +183,49 @@ function buildSettingItems(
182
183
  currentValue: choice.valueTone === "dim"
183
184
  ? theme.fg("dim", choice.value)
184
185
  : choice.value,
185
- submenu: choice.disabled
186
+ values: choice.disabled || choice.values === undefined
187
+ ? undefined
188
+ : [...choice.values],
189
+ submenu: choice.disabled || choice.values !== undefined
186
190
  ? undefined
187
191
  : () => createImmediateSelectionComponent(choice.id, done),
188
192
  }));
189
193
  }
190
194
 
195
+ /**
196
+ * @brief Writes one best-effort selected row index into a `SettingsList` instance.
197
+ * @details Uses reflective access so pi-usereq can preserve focus across menu re-renders without depending on the private field at compile time. Runtime is O(1). Side effect: mutates the underlying `SettingsList` selection state when the field exists.
198
+ * @param[in,out] settingsList {SettingsList} Mutable settings-list instance.
199
+ * @param[in] selectedIndex {number} Zero-based row index to restore.
200
+ * @return {void} No return value.
201
+ */
202
+ function setSettingsListSelectedIndex(
203
+ settingsList: SettingsList,
204
+ selectedIndex: number,
205
+ ): void {
206
+ Reflect.set(settingsList as object, "selectedIndex", selectedIndex);
207
+ }
208
+
209
+ /**
210
+ * @brief Reads the current selected row index from a `SettingsList` instance.
211
+ * @details Uses reflective access so pi-usereq can report the current focused row through the offline bridge without referencing the private field in the static type system. Runtime is O(1). No external state is mutated.
212
+ * @param[in] settingsList {SettingsList} Settings-list instance.
213
+ * @return {number | undefined} Zero-based selected row index when available.
214
+ */
215
+ function getSettingsListSelectedIndex(
216
+ settingsList: SettingsList,
217
+ ): number | undefined {
218
+ const selectedIndex = Reflect.get(settingsList as object, "selectedIndex");
219
+ return typeof selectedIndex === "number" ? selectedIndex : undefined;
220
+ }
221
+
191
222
  /**
192
223
  * @brief Renders one shared pi-usereq settings menu and resolves the selected action.
193
- * @details Uses `ctx.ui.custom(...)` plus `SettingsList` so every configuration menu shares pi.dev styling, right-aligned current values, circular scrolling, bottom-line descriptions, and optional disabled rows. The returned custom component also exposes an offline bridge for deterministic tests and debug harnesses. Runtime is O(n) in visible choice count plus user interaction cost. Side effects are limited to transient custom-UI rendering.
224
+ * @details Uses `ctx.ui.custom(...)` plus `SettingsList` so every configuration menu shares pi.dev styling, right-aligned current values, circular scrolling, bottom-line descriptions, optional disabled rows, and inline toggle cycles that do not close the menu. When callers provide `getChoices(...)`, dependent rows are rebuilt after inline changes while preserving focus on the changed row. The returned custom component also exposes an offline bridge for deterministic tests and debug harnesses. Runtime is O(n) in visible choice count plus user interaction cost. Side effects are limited to transient custom-UI rendering and caller-owned inline-change callbacks.
194
225
  * @param[in] ctx {ExtensionCommandContext} Active command context.
195
226
  * @param[in] title {string} Menu title displayed in the heading and offline bridge.
196
227
  * @param[in] choices {PiUsereqSettingsMenuChoice[]} Ordered menu-choice vector.
197
- * @param[in] options {PiUsereqSettingsMenuOptions | undefined} Optional initial-focus override.
228
+ * @param[in] options {PiUsereqSettingsMenuOptions | undefined} Optional initial-focus override plus inline-change behavior.
198
229
  * @return {Promise<string | undefined>} Selected choice identifier or `undefined` when cancelled.
199
230
  * @satisfies REQ-151, REQ-152, REQ-153, REQ-154, REQ-156, REQ-192
200
231
  */
@@ -211,22 +242,43 @@ export async function showPiUsereqSettingsMenu(
211
242
  0,
212
243
  0,
213
244
  );
214
- const settingsList = new SettingsList(
215
- buildSettingItems(theme, choices, done),
216
- Math.min(Math.max(choices.length, 1), 12),
217
- buildPiUsereqSettingsListTheme(theme),
218
- () => undefined,
219
- () => done(undefined),
220
- );
221
- const initialSelectedIndex = options.initialSelectedId === undefined
222
- ? 0
223
- : choices.findIndex((choice) => choice.id === options.initialSelectedId);
224
- if (initialSelectedIndex >= 0) {
225
- (settingsList as SettingsList & { selectedIndex: number }).selectedIndex = initialSelectedIndex;
226
- }
227
- container.addChild(titleText);
228
- container.addChild(new Text("", 0, 0));
229
- container.addChild(settingsList);
245
+ const spacer = new Text("", 0, 0);
246
+ let currentChoices = options.getChoices?.() ?? choices;
247
+ let settingsList: SettingsList;
248
+
249
+ const buildSettingsList = (
250
+ menuChoices: PiUsereqSettingsMenuChoice[],
251
+ selectedChoiceId?: string,
252
+ ): SettingsList => {
253
+ const nextSettingsList = new SettingsList(
254
+ buildSettingItems(theme, menuChoices, done),
255
+ Math.min(Math.max(menuChoices.length, 1), 12),
256
+ buildPiUsereqSettingsListTheme(theme),
257
+ (choiceId, newValue) => {
258
+ options.onChange?.(choiceId, newValue);
259
+ rebuildMenu(choiceId);
260
+ },
261
+ () => done(undefined),
262
+ );
263
+ const initialSelectedIndex = selectedChoiceId === undefined
264
+ ? 0
265
+ : menuChoices.findIndex((choice) => choice.id === selectedChoiceId);
266
+ if (initialSelectedIndex >= 0) {
267
+ setSettingsListSelectedIndex(nextSettingsList, initialSelectedIndex);
268
+ }
269
+ return nextSettingsList;
270
+ };
271
+
272
+ const rebuildMenu = (selectedChoiceId?: string): void => {
273
+ currentChoices = options.getChoices?.() ?? choices;
274
+ settingsList = buildSettingsList(currentChoices, selectedChoiceId);
275
+ container.clear();
276
+ container.addChild(titleText);
277
+ container.addChild(spacer);
278
+ container.addChild(settingsList);
279
+ };
280
+
281
+ rebuildMenu(options.initialSelectedId);
230
282
 
231
283
  const component: PiUsereqSettingsMenuComponent = {
232
284
  render(width: number): string[] {
@@ -242,13 +294,18 @@ export async function showPiUsereqSettingsMenu(
242
294
  },
243
295
  __piUsereqSettingsMenu: {
244
296
  title,
245
- choices,
246
- selectedChoiceId: initialSelectedIndex >= 0 ? choices[initialSelectedIndex]?.id : undefined,
297
+ get choices(): PiUsereqSettingsMenuChoice[] {
298
+ return currentChoices;
299
+ },
300
+ get selectedChoiceId(): string | undefined {
301
+ const selectedIndex = getSettingsListSelectedIndex(settingsList) ?? 0;
302
+ return currentChoices[selectedIndex]?.id;
303
+ },
247
304
  selectByLabel(label: string): boolean {
248
- const choice = choices.find(
305
+ const choice = currentChoices.find(
249
306
  (candidate) => candidate.label === label || candidate.id === label,
250
307
  );
251
- if (!choice || choice.disabled) {
308
+ if (!choice) {
252
309
  return false;
253
310
  }
254
311
  done(choice.id);
@@ -239,15 +239,15 @@ export function runFilesTokens(files: string[]): ToolResult {
239
239
  }
240
240
 
241
241
  /**
242
- * @brief Generates the monolithic references markdown for explicit files.
243
- * @details Delegates to `generateMarkdown(...)`, keeps output paths relative to the caller cwd, and returns the Python-compatible markdown document through stdout. Runtime is O(F + S). Side effects are limited to filesystem reads and optional stderr logging.
242
+ * @brief Generates the monolithic summary markdown for explicit files.
243
+ * @details Delegates to `generateMarkdown(...)`, keeps output paths relative to the caller cwd, and returns the Python-compatible summary markdown document through stdout. Runtime is O(F + S). Side effects are limited to filesystem reads and optional stderr logging.
244
244
  * @param[in] files {string[]} Explicit file paths.
245
245
  * @param[in] cwd {string} Base directory used for relative output paths. Defaults to `process.cwd()`.
246
246
  * @param[in] verbose {boolean} When `true`, emit per-file progress diagnostics to stderr.
247
247
  * @return {ToolResult} Successful tool result containing monolithic markdown.
248
248
  * @satisfies REQ-011, REQ-076, REQ-077, REQ-078, REQ-079
249
249
  */
250
- export function runFilesReferences(files: string[], cwd = process.cwd(), verbose = false): ToolResult {
250
+ export function runFilesSummarize(files: string[], cwd = process.cwd(), verbose = false): ToolResult {
251
251
  try {
252
252
  return ok(`${generateMarkdown(files, verbose, cwd)}\n`);
253
253
  } catch (error) {
@@ -286,8 +286,8 @@ export function runFilesSearch(argsList: string[], enableLineNumbers = false, ve
286
286
  }
287
287
 
288
288
  /**
289
- * @brief Generates the monolithic references markdown for configured source directories.
290
- * @details Resolves the project base, collects configured source files, prepends the repository file-structure markdown block, and returns the Python-compatible references document through stdout. Runtime is O(F log F + S). Side effects are limited to filesystem reads and optional stderr logging.
289
+ * @brief Generates the monolithic summary markdown for configured source directories.
290
+ * @details Resolves the project base, collects configured source files, prepends the repository file-structure markdown block, and returns the Python-compatible summary document through stdout. Runtime is O(F log F + S). Side effects are limited to filesystem reads and optional stderr logging.
291
291
  * @param[in] projectBase {string} Candidate project root.
292
292
  * @param[in] config {UseReqConfig | undefined} Optional preloaded configuration.
293
293
  * @param[in] verbose {boolean} When `true`, emit per-file diagnostics to stderr.
@@ -295,7 +295,7 @@ export function runFilesSearch(argsList: string[], enableLineNumbers = false, ve
295
295
  * @throws {ReqError} Throws when no source files are found or no file can be analyzed.
296
296
  * @satisfies REQ-014, REQ-076, REQ-077, REQ-078, REQ-079
297
297
  */
298
- export function runReferences(projectBase: string, config?: UseReqConfig, verbose = false): ToolResult {
298
+ export function runSummarize(projectBase: string, config?: UseReqConfig, verbose = false): ToolResult {
299
299
  const [base, srcDirs] = resolveProjectSrcDirs(projectBase, config);
300
300
  const files = collectSourceFiles(srcDirs, base);
301
301
  if (files.length === 0) fail("Error: no source files found in configured directories.", 1);
@@ -308,6 +308,26 @@ export function runReferences(projectBase: string, config?: UseReqConfig, verbos
308
308
  }
309
309
  }
310
310
 
311
+ /**
312
+ * @brief Writes configured project references markdown to the canonical docs file.
313
+ * @details Reuses `runSummarize(...)` to generate the same file-structure-plus-summary markdown, resolves `<docs-dir>/REFERENCES.md` from the effective project configuration, overwrites the target file, and returns the status-only stdout `success`. Runtime is O(F log F + S) plus one file write. Side effects include filesystem writes.
314
+ * @param[in] projectBase {string} Candidate project root.
315
+ * @param[in] config {UseReqConfig | undefined} Optional preloaded configuration.
316
+ * @param[in] verbose {boolean} When `true`, emit per-file diagnostics to stderr during summary generation.
317
+ * @return {ToolResult} Successful tool result containing the status-only stdout payload.
318
+ * @throws {ReqError} Throws when source discovery, summary generation, or file writing fails.
319
+ * @satisfies REQ-293
320
+ */
321
+ export function runReferences(projectBase: string, config?: UseReqConfig, verbose = false): ToolResult {
322
+ const base = resolveProjectBase(projectBase);
323
+ const effectiveConfig = config ?? loadConfig(base);
324
+ const docsDir = effectiveConfig["docs-dir"].replace(/[/\\]+$/, "");
325
+ const referencesPath = path.join(base, docsDir, "REFERENCES.md");
326
+ const summarizeResult = runSummarize(base, effectiveConfig, verbose);
327
+ fs.writeFileSync(referencesPath, summarizeResult.stdout, "utf8");
328
+ return ok("success\n");
329
+ }
330
+
311
331
  /**
312
332
  * @brief Compresses all source files from configured source directories.
313
333
  * @details Resolves the project base, collects source files, and delegates to `compressFiles`. Runtime is O(F + S). Side effects are limited to filesystem reads and optional stderr logging.