pi-usereq 0.11.0 → 0.13.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 (53) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/pi-usereq/docs/REFERENCES.md +1135 -834
  5. package/pi-usereq/docs/REQUIREMENTS.md +177 -110
  6. package/pi-usereq/docs/WORKFLOW.md +244 -79
  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/config.ts +541 -180
  11. package/src/core/extension-status.ts +69 -12
  12. package/src/core/path-context.ts +19 -4
  13. package/src/core/pi-notify.ts +5 -5
  14. package/src/core/pi-usereq-tools.ts +4 -2
  15. package/src/core/prompt-command-catalog.ts +4 -5
  16. package/src/core/prompt-command-runtime.ts +183 -44
  17. package/src/core/prompts.ts +0 -2
  18. package/src/core/req-references-command.ts +175 -0
  19. package/src/core/req-reset-command.ts +323 -0
  20. package/src/core/resources.ts +6 -23
  21. package/src/core/settings-menu.ts +85 -28
  22. package/src/core/tool-runner.ts +26 -6
  23. package/src/index.ts +601 -116
  24. package/tests/attended-results-scenarios.ts +15 -9
  25. package/tests/cli-command-option-parity.test.ts +53 -35
  26. package/tests/debug-extension-harness.test.ts +8 -10
  27. package/tests/extension-registration.test.ts +1204 -205
  28. package/tests/helpers.ts +29 -6
  29. package/tests/oracle-project.test.ts +4 -4
  30. package/tests/oracle-standalone.test.ts +5 -5
  31. package/src/core/reference-payload.ts +0 -752
  32. package/src/resources/prompts/references.md +0 -64
  33. /package/tests/fixtures_attended_results/project/{references.json → summarize.json} +0 -0
  34. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_c.c.json +0 -0
  35. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_cpp.cpp.json +0 -0
  36. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_csharp.cs.json +0 -0
  37. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_elixir.ex.json +0 -0
  38. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_go.go.json +0 -0
  39. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_haskell.hs.json +0 -0
  40. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_java.java.json +0 -0
  41. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_javascript.js.json +0 -0
  42. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_kotlin.kt.json +0 -0
  43. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_lua.lua.json +0 -0
  44. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_perl.pl.json +0 -0
  45. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_php.php.json +0 -0
  46. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_python.py.json +0 -0
  47. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_ruby.rb.json +0 -0
  48. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_rust.rs.json +0 -0
  49. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_scala.scala.json +0 -0
  50. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_shell.sh.json +0 -0
  51. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_swift.swift.json +0 -0
  52. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_typescript.ts.json +0 -0
  53. /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_zig.zig.json +0 -0
@@ -5,10 +5,18 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import piUsereqExtension from "../src/index.ts";
8
+ import {
9
+ REQ_REFERENCES_COMMAND_DESCRIPTION,
10
+ REQ_REFERENCES_COMMIT_MESSAGE,
11
+ } from "../src/core/req-references-command.js";
12
+ import {
13
+ REQ_RESET_COMMAND_DESCRIPTION,
14
+ } from "../src/core/req-reset-command.js";
8
15
  import {
9
16
  createStaticCheckLanguageConfig,
10
17
  DEFAULT_DOCS_DIR,
11
18
  getDefaultConfig,
19
+ getGlobalConfigPath,
12
20
  getProjectConfigPath,
13
21
  loadConfig,
14
22
  } from "../src/core/config.js";
@@ -45,11 +53,19 @@ import {
45
53
  } from "../src/core/pi-usereq-tools.js";
46
54
  import {
47
55
  createPiUsereqStatusController,
56
+ renderPiUsereqStatus,
48
57
  setPiUsereqStatusConfig,
49
58
  setPiUsereqWorkflowState,
50
59
  } from "../src/core/extension-status.js";
60
+ import { showPiUsereqSettingsMenu } from "../src/core/settings-menu.js";
51
61
  import { getSupportedStaticCheckLanguageSupport } from "../src/core/static-check.js";
52
- import { createTempDir, initFixtureRepo } from "./helpers.js";
62
+ import {
63
+ createTempDir,
64
+ initFixtureRepo,
65
+ readGlobalConfigJson,
66
+ readProjectConfigJson,
67
+ saveFixtureConfigs,
68
+ } from "./helpers.js";
53
69
 
54
70
  /**
55
71
  * @brief Describes one fake extension command registration captured during tests.
@@ -82,17 +98,16 @@ type RegisteredTool = {
82
98
  type RegisteredShortcut = { description?: string; handler: (ctx: any) => Promise<void> | void };
83
99
 
84
100
  /**
85
- * @brief Applies targeted `.pi-usereq.json` overrides inside one test project.
86
- * @details Loads the persisted config JSON, merges the supplied top-level overrides, and writes the updated payload back with a trailing newline so prompt-command tests can script worktree and notification behavior deterministically. Runtime is O(n) in config size. Side effects include filesystem reads and file overwrite.
101
+ * @brief Applies targeted effective-config overrides inside one test project.
102
+ * @details Loads the merged local/global config, merges the supplied top-level overrides, rewrites the split Node config files plus the oracle mirror, and commits the repository-local config artifacts when the fixture is a git repo. Runtime is O(n) in config size. Side effects include filesystem reads, file overwrite, and optional git commits.
87
103
  * @param[in] projectBase {string} Fixture project root.
88
104
  * @param[in] overrides {Record<string, unknown>} Top-level config overrides.
89
105
  * @return {void} No return value.
90
106
  */
91
107
  function writeProjectConfigOverrides(projectBase: string, overrides: Record<string, unknown>): void {
92
- const configPath = getProjectConfigPath(projectBase);
93
- const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
108
+ const config = loadConfig(projectBase);
94
109
  Object.assign(config, overrides);
95
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
110
+ saveFixtureConfigs(projectBase, config);
96
111
  if (fs.existsSync(path.join(projectBase, ".git"))) {
97
112
  const gitEnv = {
98
113
  ...process.env,
@@ -429,7 +444,7 @@ function buildExpectedFakeBranchValue(basePath: string | undefined): string {
429
444
 
430
445
  /**
431
446
  * @brief Builds the expected fake context-gauge payload for assertions.
432
- * @details Resolves the documented icon thresholds for `0`, `>0-<25`, `>=25-<50`, `>=50-<75`, and `>=75-<90`, emits the non-blinking error full icon for `>=90-<100`, and emits the blinking error full icon for `>=100`. Runtime is O(1). No external state is mutated.
447
+ * @details Resolves the documented icon thresholds for `0`, `>0-<25`, `>=25-<50`, `>=50-<75`, and `>=75-<90`, renders those non-error bands with the same `warning` token used by the `status` value, emits the non-blinking error full icon for `>=90-<100`, and emits the blinking error full icon for `>=100`. Runtime is O(1). No external state is mutated.
433
448
  * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
434
449
  * @return {string} Encoded context-gauge string.
435
450
  */
@@ -439,7 +454,7 @@ function buildExpectedFakeContextBar(options: {
439
454
  }): string {
440
455
  const percent = options.percent;
441
456
  if (percent === undefined || percent === null || percent <= 0) {
442
- return "▕_▏";
457
+ return formatFakeThemeForeground("warning", "▕_▏");
443
458
  }
444
459
  if (percent >= 100) {
445
460
  return formatFakeThemeForeground("error", "\u001b[5m▕█▏\u001b[25m");
@@ -448,15 +463,15 @@ function buildExpectedFakeContextBar(options: {
448
463
  return formatFakeThemeForeground("error", "▕█▏");
449
464
  }
450
465
  if (percent < 25) {
451
- return "▕▂▏";
466
+ return formatFakeThemeForeground("warning", "▕▂▏");
452
467
  }
453
468
  if (percent < 50) {
454
- return "▕▄▏";
469
+ return formatFakeThemeForeground("warning", "▕▄▏");
455
470
  }
456
471
  if (percent < 75) {
457
- return "▕▆▏";
472
+ return formatFakeThemeForeground("warning", "▕▆▏");
458
473
  }
459
- return "▕█▏";
474
+ return formatFakeThemeForeground("warning", "▕█▏");
460
475
  }
461
476
 
462
477
  /**
@@ -510,13 +525,13 @@ function buildExpectedFakeBasePath(cwd: string): string {
510
525
  }
511
526
 
512
527
  /**
513
- * @brief Formats one expected home-relative base path for notify-template assertions.
514
- * @details Resolves the supplied cwd, emits `~` or `~/...` when the path is inside the current user home directory, and otherwise returns the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
515
- * @param[in] cwd {string} Runtime working directory.
516
- * @return {string} Home-relative or absolute base path used by notification templates.
528
+ * @brief Formats one expected home-relative display path.
529
+ * @details Resolves the supplied path, emits `~` or `~/...` when the path is inside the current user home directory, and otherwise returns the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
530
+ * @param[in] targetPath {string} Absolute or relative path candidate.
531
+ * @return {string} Home-relative or absolute display path.
517
532
  */
518
- function buildExpectedNotifyBasePath(cwd: string): string {
519
- const resolvedPath = path.resolve(cwd);
533
+ function buildExpectedDisplayPath(targetPath: string): string {
534
+ const resolvedPath = path.resolve(targetPath);
520
535
  const homePath = path.resolve(process.env.HOME ?? os.homedir());
521
536
  if (resolvedPath === homePath) {
522
537
  return "~";
@@ -529,15 +544,32 @@ function buildExpectedNotifyBasePath(cwd: string): string {
529
544
  }
530
545
 
531
546
  /**
532
- * @brief Formats the expected `Show configuration` path value.
533
- * @details Resolves the current project config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
547
+ * @brief Formats one expected home-relative base path for notify-template assertions.
548
+ * @details Delegates to the generic display-path formatter so notify-template assertions match the runtime `~`-relative path contract. Runtime is O(p) in path length. No external state is mutated.
549
+ * @param[in] cwd {string} Runtime working directory.
550
+ * @return {string} Home-relative or absolute base path used by notification templates.
551
+ */
552
+ function buildExpectedNotifyBasePath(cwd: string): string {
553
+ return buildExpectedDisplayPath(cwd);
554
+ }
555
+
556
+ /**
557
+ * @brief Formats the expected `Show local configuration` path value.
558
+ * @details Resolves the current local project config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
534
559
  * @param[in] cwd {string} Runtime working directory.
535
- * @return {string} `~`-relative or absolute config-path display string.
560
+ * @return {string} `~`-relative or absolute local config-path display string.
561
+ */
562
+ function buildExpectedShowLocalConfigPath(cwd: string): string {
563
+ return buildExpectedDisplayPath(getProjectConfigPath(cwd));
564
+ }
565
+
566
+ /**
567
+ * @brief Formats the expected `Show global configuration` path value.
568
+ * @details Resolves the current global config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
569
+ * @return {string} `~`-relative or absolute global config-path display string.
536
570
  */
537
- function buildExpectedShowConfigPath(cwd: string): string {
538
- return buildExpectedNotifyBasePath(path.dirname(getProjectConfigPath(cwd))) === buildExpectedNotifyBasePath(cwd)
539
- ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq.json`
540
- : getProjectConfigPath(cwd).split(path.sep).join("/");
571
+ function buildExpectedShowGlobalConfigPath(): string {
572
+ return buildExpectedDisplayPath(getGlobalConfigPath());
541
573
  }
542
574
 
543
575
  /**
@@ -785,6 +817,7 @@ test("extension registers prompt and config commands while exposing tool capabil
785
817
  "req-recreate",
786
818
  "req-refactor",
787
819
  "req-references",
820
+ "req-reset",
788
821
  "req-renumber",
789
822
  "req-workflow",
790
823
  "req-write",
@@ -795,9 +828,10 @@ test("extension registers prompt and config commands while exposing tool capabil
795
828
 
796
829
  for (const name of [
797
830
  "files-tokens",
798
- "files-references",
831
+ "files-summarize",
799
832
  "files-compress",
800
833
  "files-search",
834
+ "summarize",
801
835
  "references",
802
836
  "compress",
803
837
  "search",
@@ -813,9 +847,10 @@ test("extension registers prompt and config commands while exposing tool capabil
813
847
  const toolNames = pi.tools.map((tool: RegisteredTool) => tool.name).sort();
814
848
  for (const name of [
815
849
  "files-tokens",
816
- "files-references",
850
+ "files-summarize",
817
851
  "files-compress",
818
852
  "files-search",
853
+ "summarize",
819
854
  "references",
820
855
  "compress",
821
856
  "search",
@@ -827,6 +862,46 @@ test("extension registers prompt and config commands while exposing tool capabil
827
862
  }
828
863
  });
829
864
 
865
+ /**
866
+ * @brief Verifies `req-references` registration is independent from bundled prompt inventory.
867
+ * @details Confirms `references` is absent from `PROMPT_COMMAND_NAMES` while extension activation still registers `req-references` with the fixed dedicated description. Runtime is O(1). Side effects are limited to extension registration.
868
+ * @return {void} No return value.
869
+ * @throws {AssertionError} Throws when `references` remains in the bundled prompt inventory or the command description diverges.
870
+ * @satisfies TST-102
871
+ */
872
+ test("req-references registers outside the bundled prompt inventory and keeps its dedicated description", () => {
873
+ const bundledPromptNames = PROMPT_COMMAND_NAMES as readonly string[];
874
+ assert.equal(bundledPromptNames.includes("references"), false);
875
+
876
+ const pi = createFakePi();
877
+ piUsereqExtension(pi);
878
+
879
+ assert.equal(
880
+ pi.commands.get("req-references")?.description,
881
+ REQ_REFERENCES_COMMAND_DESCRIPTION,
882
+ );
883
+ });
884
+
885
+ /**
886
+ * @brief Verifies `req-reset` registration is independent from bundled prompt inventory.
887
+ * @details Confirms `reset` is absent from `PROMPT_COMMAND_NAMES` while extension activation still registers `req-reset` with the fixed dedicated description. Runtime is O(1). Side effects are limited to extension registration.
888
+ * @return {void} No return value.
889
+ * @throws {AssertionError} Throws when `reset` remains in the bundled prompt inventory or the command description diverges.
890
+ * @satisfies TST-105
891
+ */
892
+ test("req-reset registers outside the bundled prompt inventory and keeps its dedicated description", () => {
893
+ const bundledPromptNames = PROMPT_COMMAND_NAMES as readonly string[];
894
+ assert.equal(bundledPromptNames.includes("reset"), false);
895
+
896
+ const pi = createFakePi();
897
+ piUsereqExtension(pi);
898
+
899
+ assert.equal(
900
+ pi.commands.get("req-reset")?.description,
901
+ REQ_RESET_COMMAND_DESCRIPTION,
902
+ );
903
+ });
904
+
830
905
  test("token tools register agent-oriented descriptions and schema details", () => {
831
906
  const pi = createFakePi();
832
907
  piUsereqExtension(pi);
@@ -845,24 +920,37 @@ test("token tools register agent-oriented descriptions and schema details", () =
845
920
  assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
846
921
  });
847
922
 
848
- test("reference tools register agent-oriented descriptions and schema details", () => {
923
+ test("summary tools register agent-oriented descriptions and schema details", () => {
924
+ const pi = createFakePi();
925
+ piUsereqExtension(pi);
926
+
927
+ const filesSummarize = pi.tools.find((tool: RegisteredTool) => tool.name === "files-summarize");
928
+ const summarize = pi.tools.find((tool: RegisteredTool) => tool.name === "summarize");
929
+ assert.ok(filesSummarize, "missing files-summarize tool");
930
+ assert.ok(summarize, "missing summarize tool");
931
+
932
+ assert.match(filesSummarize.description ?? "", /monolithic summary markdown report/);
933
+ assert.ok(filesSummarize.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
934
+ assert.ok(filesSummarize.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
935
+ assert.match(String((filesSummarize.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
936
+
937
+ assert.match(summarize.description ?? "", /configured project source directories/);
938
+ assert.ok(summarize.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
939
+ assert.ok(summarize.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
940
+ assert.match(String((summarize.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
941
+ });
942
+
943
+ test("references tool registers agent-oriented descriptions and schema details", () => {
849
944
  const pi = createFakePi();
850
945
  piUsereqExtension(pi);
851
946
 
852
- const filesReferences = pi.tools.find((tool: RegisteredTool) => tool.name === "files-references");
853
947
  const references = pi.tools.find((tool: RegisteredTool) => tool.name === "references");
854
- assert.ok(filesReferences, "missing files-references tool");
855
948
  assert.ok(references, "missing references tool");
856
949
 
857
- assert.match(filesReferences.description ?? "", /monolithic references markdown report/);
858
- assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
859
- assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
860
- assert.match(String((filesReferences.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
861
-
862
- assert.match(references.description ?? "", /configured project source directories/);
863
- assert.ok(references.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
864
- assert.ok(references.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
865
- assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
950
+ assert.match(references.description ?? "", /configured project source directories and configured docs-dir/);
951
+ assert.ok(references.promptGuidelines?.some((line) => line.includes("overwrite `<docs-dir>/REFERENCES.md`")));
952
+ assert.ok(references.promptGuidelines?.some((line) => line.includes("generated markdown is never returned")));
953
+ assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /content\[0\]\.text returns only `success` or `error: <diagnostic>`/);
866
954
  });
867
955
 
868
956
  test("search tools register agent-oriented descriptions and schema details", () => {
@@ -913,9 +1001,10 @@ test("agent tools define custom renderResult with compact and expanded structure
913
1001
  piUsereqExtension(pi);
914
1002
  const toolNames = [
915
1003
  "files-tokens",
916
- "files-references",
1004
+ "files-summarize",
917
1005
  "files-compress",
918
1006
  "files-search",
1007
+ "summarize",
919
1008
  "references",
920
1009
  "compress",
921
1010
  "search",
@@ -963,7 +1052,7 @@ test("files-tokens returns monolithic token summary text with execution diagnost
963
1052
  docs: {
964
1053
  "REQUIREMENTS.md": "---\ntitle: Requirements\n---\n# Requirements\nAlpha\nBeta\n",
965
1054
  "WORKFLOW.md": "# Workflow\nStep one\n",
966
- "REFERENCES.md": "# References\nEntry one\n",
1055
+ "REFERENCES.md": "# Summarize\nEntry one\n",
967
1056
  },
968
1057
  });
969
1058
  try {
@@ -1011,7 +1100,7 @@ test("files-tokens defers js-tiktoken loading until execution and returns monoli
1011
1100
  }
1012
1101
  });
1013
1102
 
1014
- test("files-references returns monolithic references markdown", async () => {
1103
+ test("files-summarize returns monolithic summary markdown", async () => {
1015
1104
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1016
1105
  const samplePath = path.join(projectBase, "src", "sample.ts");
1017
1106
  fs.writeFileSync(samplePath, `/**
@@ -1032,7 +1121,7 @@ export function buildSample(input: string): string {
1032
1121
  try {
1033
1122
  const pi = createFakePi();
1034
1123
  piUsereqExtension(pi);
1035
- const result = await executeRegisteredTool(pi, "files-references", projectBase, {
1124
+ const result = await executeRegisteredTool(pi, "files-summarize", projectBase, {
1036
1125
  files: ["src/sample.ts", "src/missing.ts"],
1037
1126
  }) as {
1038
1127
  content?: Array<{ type: string; text?: string }>;
@@ -1051,7 +1140,7 @@ export function buildSample(input: string): string {
1051
1140
  }
1052
1141
  });
1053
1142
 
1054
- test("references returns monolithic project references markdown", async () => {
1143
+ test("summarize returns monolithic project summary markdown", async () => {
1055
1144
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1056
1145
  fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
1057
1146
  fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
@@ -1059,7 +1148,7 @@ test("references returns monolithic project references markdown", async () => {
1059
1148
  try {
1060
1149
  const pi = createFakePi();
1061
1150
  piUsereqExtension(pi);
1062
- const result = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1151
+ const result = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
1063
1152
  content?: Array<{ type: string; text?: string }>;
1064
1153
  details?: { execution: { code: number; stderr_lines?: string[] } };
1065
1154
  };
@@ -1077,6 +1166,85 @@ test("references returns monolithic project references markdown", async () => {
1077
1166
  }
1078
1167
  });
1079
1168
 
1169
+ test("references overwrites the configured REFERENCES.md and returns status-only success", async () => {
1170
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
1171
+ fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
1172
+ fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
1173
+ fs.writeFileSync(path.join(projectBase, "src", "nested", "beta.ts"), "export function beta(): number {\n return 2;\n}\n", "utf8");
1174
+ const docsDir = path.join(projectBase, "docs", "custom");
1175
+ fs.mkdirSync(docsDir, { recursive: true });
1176
+ fs.writeFileSync(path.join(docsDir, "REFERENCES.md"), "stale\n", "utf8");
1177
+ const configPath = getProjectConfigPath(projectBase);
1178
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
1179
+ config["docs-dir"] = "docs/custom";
1180
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
1181
+
1182
+ try {
1183
+ const pi = createFakePi();
1184
+ piUsereqExtension(pi);
1185
+ const referencesResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1186
+ content?: Array<{ type: string; text?: string }>;
1187
+ details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
1188
+ };
1189
+ const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
1190
+ content?: Array<{ type: string; text?: string }>;
1191
+ };
1192
+
1193
+ const referencesPath = path.join(docsDir, "REFERENCES.md");
1194
+ const writtenText = fs.readFileSync(referencesPath, "utf8");
1195
+ assert.equal(referencesResult.content?.[0]?.text ?? "", "success");
1196
+ assert.equal(writtenText.trimEnd(), summarizeResult.content?.[0]?.text ?? "");
1197
+ assert.doesNotMatch(writtenText, /^stale$/m);
1198
+ assert.equal(referencesResult.details?.execution.code, 0);
1199
+ assert.deepEqual(referencesResult.details?.execution.stderr_lines, undefined);
1200
+ assert.deepEqual(referencesResult.details?.execution.stdout_lines, undefined);
1201
+ } finally {
1202
+ fs.rmSync(projectBase, { recursive: true, force: true });
1203
+ }
1204
+ });
1205
+
1206
+ test("references returns status-only errors for source-discovery and write failures", async () => {
1207
+ const sourceFailure = initFixtureRepo({ fixtures: [] });
1208
+ try {
1209
+ const pi = createFakePi();
1210
+ piUsereqExtension(pi);
1211
+ const result = await executeRegisteredTool(pi, "references", sourceFailure.projectBase, {}) as {
1212
+ content?: Array<{ type: string; text?: string }>;
1213
+ details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
1214
+ };
1215
+
1216
+ assert.equal(result.content?.[0]?.text ?? "", "error: no source files found in configured directories.");
1217
+ assert.equal(result.details?.execution.code, 1);
1218
+ assert.deepEqual(result.details?.execution.stdout_lines, undefined);
1219
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => line.includes("no source files found in configured directories.")));
1220
+ } finally {
1221
+ fs.rmSync(sourceFailure.projectBase, { recursive: true, force: true });
1222
+ }
1223
+
1224
+ const writeFailure = initFixtureRepo({ fixtures: [] });
1225
+ fs.writeFileSync(path.join(writeFailure.projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
1226
+ const writeFailureConfigPath = getProjectConfigPath(writeFailure.projectBase);
1227
+ const writeFailureConfig = JSON.parse(fs.readFileSync(writeFailureConfigPath, "utf8")) as Record<string, unknown>;
1228
+ writeFailureConfig["docs-dir"] = "docs/missing/custom";
1229
+ fs.writeFileSync(writeFailureConfigPath, `${JSON.stringify(writeFailureConfig, null, 2)}\n`, "utf8");
1230
+
1231
+ try {
1232
+ const pi = createFakePi();
1233
+ piUsereqExtension(pi);
1234
+ const result = await executeRegisteredTool(pi, "references", writeFailure.projectBase, {}) as {
1235
+ content?: Array<{ type: string; text?: string }>;
1236
+ details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
1237
+ };
1238
+
1239
+ assert.match(result.content?.[0]?.text ?? "", /^error: ENOENT:/);
1240
+ assert.equal(result.details?.execution.code, 1);
1241
+ assert.deepEqual(result.details?.execution.stdout_lines, undefined);
1242
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => /^error: ENOENT:/.test(line)));
1243
+ } finally {
1244
+ fs.rmSync(writeFailure.projectBase, { recursive: true, force: true });
1245
+ }
1246
+ });
1247
+
1080
1248
  test("files-compress returns monolithic compression markdown", async () => {
1081
1249
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1082
1250
  const samplePath = path.join(projectBase, "src", "sample.ts");
@@ -1264,21 +1432,21 @@ func (s *Server) Start(ctx context.Context) error {
1264
1432
  const pi = createFakePi();
1265
1433
  piUsereqExtension(pi);
1266
1434
 
1267
- const filesReferencesResult = await executeRegisteredTool(pi, "files-references", projectBase, {
1435
+ const filesSummarizeResult = await executeRegisteredTool(pi, "files-summarize", projectBase, {
1268
1436
  files: ["src/sample.go"],
1269
1437
  }) as {
1270
1438
  content?: Array<{ type: string; text?: string }>;
1271
1439
  details?: { execution: { code: number } };
1272
1440
  };
1273
- assert.match(filesReferencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1274
- assert.equal(filesReferencesResult.details?.execution.code, 0);
1441
+ assert.match(filesSummarizeResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1442
+ assert.equal(filesSummarizeResult.details?.execution.code, 0);
1275
1443
 
1276
- const referencesResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1444
+ const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
1277
1445
  content?: Array<{ type: string; text?: string }>;
1278
1446
  details?: { execution: { code: number } };
1279
1447
  };
1280
- assert.match(referencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1281
- assert.equal(referencesResult.details?.execution.code, 0);
1448
+ assert.match(summarizeResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1449
+ assert.equal(summarizeResult.details?.execution.code, 0);
1282
1450
 
1283
1451
  const filesCompressResult = await executeRegisteredTool(pi, "files-compress", projectBase, {
1284
1452
  files: ["src/sample.go"],
@@ -1360,19 +1528,15 @@ test("static-check tools return monolithic text payloads with minimal execution
1360
1528
  assert.equal(staticCheckResult.details?.execution.code, 0);
1361
1529
  assert.deepEqual(staticCheckResult.details?.execution.stderr_lines, undefined);
1362
1530
 
1363
- fs.writeFileSync(
1364
- getProjectConfigPath(projectBase),
1365
- `${JSON.stringify({
1366
- ...getDefaultConfig(projectBase),
1367
- "docs-dir": DEFAULT_DOCS_DIR,
1368
- "tests-dir": "tests",
1369
- "src-dir": ["src"],
1370
- "static-check": {
1371
- TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1372
- },
1373
- }, null, 2)}\n`,
1374
- "utf8",
1375
- );
1531
+ saveFixtureConfigs(projectBase, {
1532
+ ...getDefaultConfig(projectBase),
1533
+ "docs-dir": DEFAULT_DOCS_DIR,
1534
+ "tests-dir": "tests",
1535
+ "src-dir": ["src"],
1536
+ "static-check": {
1537
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1538
+ },
1539
+ });
1376
1540
  const disabledFilesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1377
1541
  files: ["src/check.ts"],
1378
1542
  }) as {
@@ -1396,7 +1560,7 @@ test("configuration menu saves updated docs-dir in project config", async () =>
1396
1560
  assert.ok(command);
1397
1561
 
1398
1562
  await command!.handler("", createFakeCtx(cwd, { selects: ["docs-dir", "Save and close"], inputs: ["docs/custom"] }));
1399
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1563
+ const config = readProjectConfigJson(cwd);
1400
1564
  assert.equal(config["docs-dir"], "docs/custom");
1401
1565
  });
1402
1566
 
@@ -1421,7 +1585,7 @@ test("configuration menu persists trailing-slash-free relative directory values"
1421
1585
 
1422
1586
  await command!.handler("", ctx);
1423
1587
 
1424
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1588
+ const config = readProjectConfigJson(cwd);
1425
1589
  assert.equal(config["docs-dir"], "docs/custom");
1426
1590
  assert.equal(config["tests-dir"], "tests/custom");
1427
1591
  assert.deepEqual(config["src-dir"], ["src", "src/custom"]);
@@ -1441,12 +1605,14 @@ test("configuration menu forces worktree rows off and dim when auto git commit i
1441
1605
 
1442
1606
  await command!.handler("", ctx);
1443
1607
 
1444
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1608
+ const localConfig = readProjectConfigJson(cwd);
1609
+ const globalConfig = readGlobalConfigJson();
1445
1610
  const defaultConfig = getDefaultConfig(cwd);
1446
1611
  const renderedLockedMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1447
- assert.equal(config.AUTO_GIT_COMMIT, "disable");
1448
- assert.equal(config.GIT_WORKTREE_ENABLED, "disable");
1449
- assert.equal(config.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1612
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "disable");
1613
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "disable");
1614
+ assert.equal(globalConfig.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1615
+ assert.equal(Object.hasOwn(localConfig, "AUTO_GIT_COMMIT"), false);
1450
1616
  assert.match(renderedLockedMenu, /<dim>Git worktree/);
1451
1617
  assert.match(renderedLockedMenu, /<dim>Worktree prefix/);
1452
1618
  });
@@ -1467,7 +1633,7 @@ test("configuration menu omits prompt-delivery controls and persisted config omi
1467
1633
  assert.equal(fs.existsSync(getProjectConfigPath(cwd)), false);
1468
1634
  });
1469
1635
 
1470
- test("configuration menus expose show-config ordering and omit overview or notification-reference rows", async () => {
1636
+ test("configuration menus expose local/global config ordering and omit overview or notification-reference rows", async () => {
1471
1637
  const cwd = fs.mkdtempSync(
1472
1638
  path.join(process.env.HOME ?? process.cwd(), "pi-usereq-menu-structure-"),
1473
1639
  );
@@ -1493,20 +1659,28 @@ test("configuration menus expose show-config ordering and omit overview or notif
1493
1659
  "Enable tools",
1494
1660
  "Notifications",
1495
1661
  "Debug",
1496
- "Show configuration",
1662
+ "Show local configuration",
1663
+ "Show global configuration",
1497
1664
  "Reset defaults",
1498
1665
  ]);
1499
- assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1666
+ assert.match(
1667
+ renderedMenu,
1668
+ new RegExp(`<dim>${buildExpectedShowLocalConfigPath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1669
+ );
1670
+ assert.match(
1671
+ renderedMenu,
1672
+ new RegExp(`<dim>${buildExpectedShowGlobalConfigPath().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1673
+ );
1500
1674
  assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1501
1675
  assert.ok(renderedMenu.includes("disable"));
1502
1676
  assert.doesNotMatch(renderedMenu, /beep:/);
1503
1677
  assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
1504
- assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
1678
+ assert.match(renderedNotificationsMenu, /<dim>configure user\/token keys first<\/dim>/);
1505
1679
  assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1506
1680
  "Enable notification",
1507
1681
  "Notification events",
1508
1682
  "Notify command",
1509
- "Enable sound",
1683
+ "Enable sound (boot value)",
1510
1684
  "Sound events",
1511
1685
  "Sound toggle hotkey bind",
1512
1686
  "Sound command (low vol.)",
@@ -1523,15 +1697,15 @@ test("configuration menus expose show-config ordering and omit overview or notif
1523
1697
  ]);
1524
1698
  });
1525
1699
 
1526
- test("show configuration saves pending config, closes the menu, and writes the persisted project config file text into the editor", async () => {
1527
- const cwd = createTempDir("pi-usereq-menu-show-config-");
1700
+ test("show local configuration saves pending config, closes the menu, and writes the persisted local config file text into the editor", async () => {
1701
+ const cwd = createTempDir("pi-usereq-menu-show-local-config-");
1528
1702
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1529
1703
  const pi = createFakePi();
1530
1704
  piUsereqExtension(pi);
1531
1705
  const command = pi.commands.get("pi-usereq");
1532
1706
  assert.ok(command);
1533
1707
  const ctx = createFakeCtx(cwd, {
1534
- selects: ["docs-dir", "show-config"],
1708
+ selects: ["docs-dir", "show-local-config"],
1535
1709
  inputs: ["docs/custom"],
1536
1710
  });
1537
1711
 
@@ -1546,6 +1720,28 @@ test("show configuration saves pending config, closes the menu, and writes the p
1546
1720
  );
1547
1721
  });
1548
1722
 
1723
+ test("show global configuration saves pending config, closes the menu, and writes the persisted global config file text into the editor", async () => {
1724
+ const cwd = createTempDir("pi-usereq-menu-show-global-config-");
1725
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1726
+ const pi = createFakePi();
1727
+ piUsereqExtension(pi);
1728
+ const command = pi.commands.get("pi-usereq");
1729
+ assert.ok(command);
1730
+ const ctx = createFakeCtx(cwd, {
1731
+ selects: ["Auto git commit", "show-global-config"],
1732
+ });
1733
+
1734
+ await command!.handler("", ctx);
1735
+
1736
+ const persistedText = fs.readFileSync(getGlobalConfigPath(), "utf8");
1737
+ assert.equal(ctx.__state.editorText, persistedText);
1738
+ assert.match(ctx.__state.editorText, /"AUTO_GIT_COMMIT": "disable"/);
1739
+ assert.equal(
1740
+ ctx.__state.selectCalls.filter((call) => call.title === "pi-usereq").length,
1741
+ 2,
1742
+ );
1743
+ });
1744
+
1549
1745
  test("descendant configuration menus end with Reset defaults only", async () => {
1550
1746
  const cwd = createTempDir("pi-usereq-menu-terminal-rows-");
1551
1747
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1556,7 +1752,7 @@ test("descendant configuration menus end with Reset defaults only", async () =>
1556
1752
  const ctx = createFakeCtx(cwd, {
1557
1753
  selects: [
1558
1754
  "notifications",
1559
- "Enable sound",
1755
+ "Enable sound (boot value)",
1560
1756
  "Save and close",
1561
1757
  "Pushover priority",
1562
1758
  "Save and close",
@@ -1599,14 +1795,14 @@ test("descendant configuration menus end with Reset defaults only", async () =>
1599
1795
  return (ctx.__state.customRenderLines[index] ?? []).join("\n");
1600
1796
  };
1601
1797
 
1602
- assert.deepEqual(getTerminalRows("Enable sound", "high"), ["Reset defaults"]);
1798
+ assert.deepEqual(getTerminalRows("Enable sound (boot value)", "high"), ["Reset defaults"]);
1603
1799
  assert.deepEqual(getTerminalRows("Pushover priority", "High"), ["Reset defaults"]);
1604
1800
  assert.deepEqual(getTerminalRows("Enable tools", "static-check"), ["Reset defaults"]);
1605
1801
  assert.deepEqual(getTerminalRows("Debug", "Log on status"), ["Reset defaults"]);
1606
1802
  assert.deepEqual(getTerminalRows("static-check language", "Python"), ["Reset defaults"]);
1607
1803
  assert.deepEqual(getTerminalRows("Remove static code checker"), ["Reset defaults"]);
1608
1804
  assert.deepEqual(getTerminalRows("Remove source-code directory", "src"), ["Reset defaults"]);
1609
- assert.doesNotMatch(getRenderedMenu("Enable sound", "high"), /Reset defaults.*none/);
1805
+ assert.doesNotMatch(getRenderedMenu("Enable sound (boot value)", "high"), /Reset defaults.*none/);
1610
1806
  assert.doesNotMatch(getRenderedMenu("Pushover priority", "High"), /Reset defaults.*Normal/);
1611
1807
  assert.doesNotMatch(getRenderedMenu("Enable tools", "static-check"), /Reset defaults.*defaults/);
1612
1808
  assert.doesNotMatch(getRenderedMenu("Debug", "Log on status"), /Reset defaults.*running/);
@@ -1640,7 +1836,7 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1640
1836
  await command!.handler("", ctx);
1641
1837
 
1642
1838
  const renderedInitialDebugMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1643
- const persistedConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1839
+ const persistedConfig = readProjectConfigJson(cwd);
1644
1840
  const debugCalls = ctx.__state.selectCalls.filter((call) => call.title === "Debug");
1645
1841
  assert.match(renderedInitialDebugMenu, /<dim>Log file/);
1646
1842
  assert.match(renderedInitialDebugMenu, /<dim>Log on status/);
@@ -1666,7 +1862,7 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1666
1862
  selects: ["Debug", "Reset defaults", "Approve reset", "Save and close"],
1667
1863
  });
1668
1864
  await command!.handler("", resetCtx);
1669
- const resetConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1865
+ const resetConfig = readProjectConfigJson(cwd);
1670
1866
  assert.equal(resetConfig.DEBUG_ENABLED, "disable");
1671
1867
  assert.equal(resetConfig.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
1672
1868
  assert.equal(resetConfig.DEBUG_STATUS_CHANGES, "disable");
@@ -1711,17 +1907,13 @@ test("debug menu rows derive from canonical tool and prompt inventories", async
1711
1907
  test("tool debug logging honors global enablement and workflow-status filters", async () => {
1712
1908
  const idleBase = createTempDir("pi-usereq-tool-debug-idle-");
1713
1909
  fs.mkdirSync(path.dirname(getProjectConfigPath(idleBase)), { recursive: true });
1714
- fs.writeFileSync(
1715
- getProjectConfigPath(idleBase),
1716
- `${JSON.stringify({
1717
- ...getDefaultConfig(idleBase),
1718
- DEBUG_ENABLED: "enable",
1719
- DEBUG_LOG_FILE: "tool-debug.json",
1720
- DEBUG_LOG_ON_STATUS: "any",
1721
- DEBUG_ENABLED_TOOLS: ["bash"],
1722
- }, null, 2)}\n`,
1723
- "utf8",
1724
- );
1910
+ saveFixtureConfigs(idleBase, {
1911
+ ...getDefaultConfig(idleBase),
1912
+ DEBUG_ENABLED: "enable",
1913
+ DEBUG_LOG_FILE: "tool-debug.json",
1914
+ DEBUG_LOG_ON_STATUS: "any",
1915
+ DEBUG_ENABLED_TOOLS: ["bash"],
1916
+ });
1725
1917
  const idlePi = createFakePi();
1726
1918
  piUsereqExtension(idlePi);
1727
1919
  const idleCtx = createFakeCtx(idleBase);
@@ -1782,17 +1974,13 @@ test("tool debug logging honors global enablement and workflow-status filters",
1782
1974
 
1783
1975
  const disabledBase = createTempDir("pi-usereq-tool-debug-disabled-");
1784
1976
  fs.mkdirSync(path.dirname(getProjectConfigPath(disabledBase)), { recursive: true });
1785
- fs.writeFileSync(
1786
- getProjectConfigPath(disabledBase),
1787
- `${JSON.stringify({
1788
- ...getDefaultConfig(disabledBase),
1789
- DEBUG_ENABLED: "disable",
1790
- DEBUG_LOG_FILE: "tool-debug.json",
1791
- DEBUG_LOG_ON_STATUS: "any",
1792
- DEBUG_ENABLED_TOOLS: ["bash"],
1793
- }, null, 2)}\n`,
1794
- "utf8",
1795
- );
1977
+ saveFixtureConfigs(disabledBase, {
1978
+ ...getDefaultConfig(disabledBase),
1979
+ DEBUG_ENABLED: "disable",
1980
+ DEBUG_LOG_FILE: "tool-debug.json",
1981
+ DEBUG_LOG_ON_STATUS: "any",
1982
+ DEBUG_ENABLED_TOOLS: ["bash"],
1983
+ });
1796
1984
  const disabledPi = createFakePi();
1797
1985
  piUsereqExtension(disabledPi);
1798
1986
  const disabledCtx = createFakeCtx(disabledBase);
@@ -1917,6 +2105,101 @@ test("notifications menu preserves focus on toggled and edited rows", async () =
1917
2105
  assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1918
2106
  });
1919
2107
 
2108
+ /**
2109
+ * @brief Verifies inline toggle rows stay open and preserve selection for confirm keys.
2110
+ * @details Builds one shared settings-menu instance with a primary inline toggle and a dependent secondary row whose disabled styling changes after each toggle. The test proves `space` and `enter` must mutate the selected row in place, rebuild dependent rows without closing the menu, and preserve focus on the toggled row until explicit cancel.
2111
+ * @return {Promise<void>} Promise resolved after the raw-input interaction sequence completes.
2112
+ * @throws {AssertionError} Throws when the menu closes early, loses focus, or fails to rebuild dependent row styling after a toggle.
2113
+ * @satisfies REQ-192, TST-052
2114
+ */
2115
+ test("shared settings menu keeps inline toggles focused and open for space and enter", async () => {
2116
+ let primaryEnabled = true;
2117
+ let component:
2118
+ | {
2119
+ handleInput?: (data: string) => void;
2120
+ render: (width: number) => string[];
2121
+ }
2122
+ | undefined;
2123
+ let resolved = false;
2124
+ const changes: string[] = [];
2125
+ const theme = {
2126
+ fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
2127
+ bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
2128
+ bold: (text: string) => `<bold>${text}</bold>`,
2129
+ };
2130
+ const ctx = {
2131
+ ui: {
2132
+ async custom<T>(factory: (tui: { requestRender: () => void }, customTheme: typeof theme, keybindings: Record<string, never>, done: (value?: T) => void) => unknown) {
2133
+ let resolveResult!: (value: T | undefined) => void;
2134
+ const resultPromise = new Promise<T | undefined>((resolve) => {
2135
+ resolveResult = (value?: T) => {
2136
+ resolved = true;
2137
+ resolve(value);
2138
+ };
2139
+ });
2140
+ component = factory(
2141
+ { requestRender: () => undefined },
2142
+ theme,
2143
+ {},
2144
+ (value?: T) => resolveResult(value),
2145
+ ) as typeof component;
2146
+ return resultPromise;
2147
+ },
2148
+ },
2149
+ } as any;
2150
+ const buildChoices = () => [
2151
+ {
2152
+ id: "primary",
2153
+ label: "Primary",
2154
+ value: primaryEnabled ? "enable" : "disable",
2155
+ values: ["enable", "disable"],
2156
+ description: "Toggle the primary row in place.",
2157
+ },
2158
+ {
2159
+ id: "secondary",
2160
+ label: "Secondary",
2161
+ labelTone: primaryEnabled ? undefined : "dim",
2162
+ value: primaryEnabled ? "editable" : "locked",
2163
+ valueTone: primaryEnabled ? undefined : "dim",
2164
+ disabled: !primaryEnabled,
2165
+ description: "Dependent row that must rebuild without closing the menu.",
2166
+ },
2167
+ ] as any;
2168
+
2169
+ const resultPromise = showPiUsereqSettingsMenu(
2170
+ ctx,
2171
+ "Toggle menu",
2172
+ buildChoices(),
2173
+ {
2174
+ getChoices: buildChoices,
2175
+ onChange: (choiceId: string, newValue: string) => {
2176
+ if (choiceId === "primary") {
2177
+ primaryEnabled = newValue === "enable";
2178
+ changes.push(newValue);
2179
+ }
2180
+ },
2181
+ } as any,
2182
+ );
2183
+
2184
+ assert.ok(component);
2185
+ assert.doesNotMatch(component.render(120).join("\n"), /<dim>Secondary/);
2186
+
2187
+ component.handleInput?.(" ");
2188
+ await Promise.resolve();
2189
+ assert.deepEqual(changes, ["disable"]);
2190
+ assert.equal(resolved, false);
2191
+ assert.match(component.render(120).join("\n"), /<dim>Secondary/);
2192
+
2193
+ component.handleInput?.("\r");
2194
+ await Promise.resolve();
2195
+ assert.deepEqual(changes, ["disable", "enable"]);
2196
+ assert.equal(resolved, false);
2197
+ assert.doesNotMatch(component.render(120).join("\n"), /<dim>Secondary/);
2198
+
2199
+ component.handleInput?.("\x1b");
2200
+ assert.equal(await resultPromise, undefined);
2201
+ });
2202
+
1920
2203
  test("notifications reset defaults preserves non-notification settings", async () => {
1921
2204
  const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1922
2205
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1929,7 +2212,7 @@ test("notifications reset defaults preserves non-notification settings", async (
1929
2212
  "docs-dir",
1930
2213
  "notifications",
1931
2214
  "Enable notification",
1932
- "Enable sound",
2215
+ "Enable sound (boot value)",
1933
2216
  "high",
1934
2217
  "Reset defaults",
1935
2218
  "Approve reset",
@@ -1941,11 +2224,12 @@ test("notifications reset defaults preserves non-notification settings", async (
1941
2224
 
1942
2225
  await command!.handler("", ctx);
1943
2226
 
1944
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1945
- assert.equal(config["docs-dir"], "docs/custom");
1946
- assert.equal(config["notify-enabled"], false);
1947
- assert.equal(config["notify-sound"], "none");
1948
- assert.equal(config["notify-pushover-enabled"], false);
2227
+ const localConfig = readProjectConfigJson(cwd);
2228
+ const globalConfig = readGlobalConfigJson();
2229
+ assert.equal(localConfig["docs-dir"], "docs/custom");
2230
+ assert.equal(globalConfig["notify-enabled"], false);
2231
+ assert.equal(globalConfig["notify-sound"], "none");
2232
+ assert.equal(globalConfig["notify-pushover-enabled"], false);
1949
2233
  });
1950
2234
 
1951
2235
  test("top-level reset defaults restores all configuration values", async () => {
@@ -1970,11 +2254,12 @@ test("top-level reset defaults restores all configuration values", async () => {
1970
2254
 
1971
2255
  await command!.handler("", ctx);
1972
2256
 
1973
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1974
- assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1975
- assert.deepEqual(config["src-dir"], ["src"]);
1976
- assert.equal(config["notify-enabled"], false);
1977
- assert.equal(config["notify-sound"], "none");
2257
+ const localConfig = readProjectConfigJson(cwd);
2258
+ const globalConfig = readGlobalConfigJson();
2259
+ assert.equal(localConfig["docs-dir"], DEFAULT_DOCS_DIR);
2260
+ assert.deepEqual(localConfig["src-dir"], ["src"]);
2261
+ assert.equal(globalConfig["notify-enabled"], false);
2262
+ assert.equal(globalConfig["notify-sound"], "none");
1978
2263
  });
1979
2264
 
1980
2265
  test("configuration menus reuse CLI settings theme semantics", async () => {
@@ -2033,62 +2318,499 @@ test("prompt commands use the current session by default", async () => {
2033
2318
  }
2034
2319
  });
2035
2320
 
2036
- test("prompt commands skip worktree creation when auto git commit is disabled", async () => {
2321
+ /**
2322
+ * @brief Verifies successful `req-references` completion uses the specialized direct commit workflow.
2323
+ * @details Confirms the command regenerates configured `REFERENCES.md`, creates no worktree, sends no LLM user message, stages only the target file, commits the fixed message, and restores workflow state `idle`. Runtime is dominated by temporary git execution and reference generation. Side effects are limited to temporary repository mutation and command-handler notifications.
2324
+ * @return {Promise<void>} Promise resolved after success-path assertions complete.
2325
+ * @throws {AssertionError} Throws when `req-references` creates a worktree, starts a session handoff, skips the fixed commit, or leaves the repository dirty.
2326
+ * @satisfies TST-103
2327
+ */
2328
+ test("req-references updates REFERENCES.md with a direct commit workflow and no LLM session", async () => {
2037
2329
  const { projectBase } = initFixtureRepo({ fixtures: [] });
2330
+ const previousCwd = process.cwd();
2038
2331
  try {
2039
- writeProjectConfigOverrides(projectBase, {
2040
- AUTO_GIT_COMMIT: "disable",
2041
- GIT_WORKTREE_ENABLED: "enable",
2332
+ fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
2333
+ fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
2334
+ fs.writeFileSync(path.join(projectBase, "src", "nested", "beta.ts"), "export function beta(): number {\n return 2;\n}\n", "utf8");
2335
+ const docsDir = path.join(projectBase, "docs", "custom");
2336
+ fs.mkdirSync(docsDir, { recursive: true });
2337
+ fs.writeFileSync(path.join(docsDir, "REFERENCES.md"), "stale\n", "utf8");
2338
+ writeProjectConfigOverrides(projectBase, { "docs-dir": "docs/custom" });
2339
+ assert.equal(spawnSync("git", ["add", "src/alpha.ts", "src/nested/beta.ts", "docs/custom/REFERENCES.md"], {
2340
+ cwd: projectBase,
2341
+ encoding: "utf8",
2342
+ }).status, 0);
2343
+ const fixtureCommit = spawnSync("git", ["commit", "-m", "fixture sources"], {
2344
+ cwd: projectBase,
2345
+ encoding: "utf8",
2042
2346
  });
2347
+ assert.equal(fixtureCommit.status, 0, fixtureCommit.stderr);
2348
+
2349
+ const worktreeParentPath = path.dirname(projectBase);
2350
+ const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
2043
2351
  const pi = createFakePi();
2044
2352
  piUsereqExtension(pi);
2045
- const command = pi.commands.get("req-change");
2046
- assert.ok(command);
2047
2353
  const ctx = createFakeCtx(projectBase);
2354
+ const recordedStatuses: string[] = [];
2355
+ let switchSessionCalls = 0;
2356
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2357
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
2358
+ ctx.ui.setStatus = (key: string, value?: string) => {
2359
+ if (key === "pi-usereq" && value !== undefined) {
2360
+ recordedStatuses.push(value);
2361
+ }
2362
+ originalSetStatus(key, value);
2363
+ };
2364
+ ctx.switchSession = async (sessionPath: string) => {
2365
+ switchSessionCalls += 1;
2366
+ return originalSwitchSession(sessionPath);
2367
+ };
2048
2368
 
2049
- await command!.handler("Adjust docs", ctx);
2369
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2370
+ await pi.commands.get("req-references")!.handler("", ctx);
2050
2371
 
2051
- const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2052
- const expectedBasePath = buildExpectedFakeBasePath(projectBase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2053
- assert.doesNotMatch(promptText, /created worktree/);
2054
- assert.match(
2055
- promptText,
2056
- new RegExp(`worktree orchestration is disabled and context-path remains \`${expectedBasePath}\``),
2372
+ const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
2373
+ content?: Array<{ type?: string; text?: string }>;
2374
+ };
2375
+ const referencesPath = path.join(docsDir, "REFERENCES.md");
2376
+ const writtenText = fs.readFileSync(referencesPath, "utf8");
2377
+ const lastCommitMessage = spawnSync("git", ["log", "-1", "--pretty=%s"], {
2378
+ cwd: projectBase,
2379
+ encoding: "utf8",
2380
+ }).stdout.trim();
2381
+ const committedPaths = spawnSync("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], {
2382
+ cwd: projectBase,
2383
+ encoding: "utf8",
2384
+ }).stdout.trim().split(/\r?\n/).filter((line) => line !== "");
2385
+ const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
2386
+ .filter((entry) => !initialEntries.has(entry))
2387
+ .filter((entry) => /^PI-useReq-/.test(entry));
2388
+ const finalStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2389
+
2390
+ assert.equal(pi.sentUserMessages.length, 0);
2391
+ assert.equal(switchSessionCalls, 0);
2392
+ assert.deepEqual(createdWorktreeEntries, []);
2393
+ assert.equal(writtenText.trimEnd(), summarizeResult.content?.[0]?.text ?? "");
2394
+ assert.doesNotMatch(writtenText, /^stale$/m);
2395
+ assert.equal(lastCommitMessage, REQ_REFERENCES_COMMIT_MESSAGE);
2396
+ assert.deepEqual(committedPaths, ["docs/custom/REFERENCES.md"]);
2397
+ assert.equal(
2398
+ spawnSync("git", ["status", "--porcelain"], { cwd: projectBase, encoding: "utf8" }).stdout.trim(),
2399
+ "",
2400
+ );
2401
+ assert.ok(ctx.__state.notifications.some((entry) => /SUCCESS: Updated .*REFERENCES\.md/.test(entry.message)));
2402
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
2403
+ assert.ok(recordedStatuses.every((status) => !/<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
2404
+ assert.equal(
2405
+ finalStatus,
2406
+ buildExpectedFakeStatusText({
2407
+ workflowState: "idle",
2408
+ basePath: buildExpectedFakeBasePath(projectBase),
2409
+ docsDir: "docs/custom",
2410
+ testsDir: "tests",
2411
+ srcDir: ["src"],
2412
+ contextFilledCells: 0,
2413
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2414
+ }),
2057
2415
  );
2058
2416
  } finally {
2417
+ process.chdir(previousCwd);
2059
2418
  fs.rmSync(projectBase, { recursive: true, force: true });
2060
2419
  }
2061
2420
  });
2062
2421
 
2063
- test("prompt commands capture checking before handoff and running at prompt injection", async () => {
2422
+ /**
2423
+ * @brief Verifies failing `req-references` runs report errors without session handoff.
2424
+ * @details Covers both git-preflight failure and reference-generation failure, proving the command creates no worktree, sends no LLM message, transitions workflow state to `error`, and preserves the previous git commit when execution aborts. Runtime is dominated by temporary repository setup plus failing command invocations. Side effects are limited to temporary filesystem and git mutation inside fixture repositories.
2425
+ * @return {Promise<void>} Promise resolved after failure-path assertions complete.
2426
+ * @throws {AssertionError} Throws when `req-references` hides failures, mutates history, or attempts prompt-session orchestration.
2427
+ * @satisfies TST-104
2428
+ */
2429
+ test("req-references surfaces git-validation and reference-generation failures without session handoff", async () => {
2430
+ const gitFailure = initFixtureRepo({ fixtures: [] });
2431
+ const previousCwd = process.cwd();
2432
+ try {
2433
+ fs.writeFileSync(path.join(gitFailure.projectBase, "DIRTY.txt"), "dirty\n", "utf8");
2434
+ const worktreeParentPath = path.dirname(gitFailure.projectBase);
2435
+ const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
2436
+ const pi = createFakePi();
2437
+ piUsereqExtension(pi);
2438
+ const ctx = createFakeCtx(gitFailure.projectBase);
2439
+
2440
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2441
+ await assert.rejects(
2442
+ pi.commands.get("req-references")!.handler("", ctx),
2443
+ /ERROR: Git status unclear!/,
2444
+ );
2445
+
2446
+ const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
2447
+ .filter((entry) => !initialEntries.has(entry))
2448
+ .filter((entry) => /^PI-useReq-/.test(entry));
2449
+ assert.equal(pi.sentUserMessages.length, 0);
2450
+ assert.deepEqual(createdWorktreeEntries, []);
2451
+ assert.ok(ctx.__state.notifications.some((entry) => /ERROR: Git status unclear!/.test(entry.message)));
2452
+ assert.equal(
2453
+ ctx.__state.statuses.get("pi-usereq"),
2454
+ buildExpectedFakeStatusText({
2455
+ workflowState: "error",
2456
+ basePath: buildExpectedFakeBasePath(gitFailure.projectBase),
2457
+ docsDir: DEFAULT_DOCS_DIR,
2458
+ testsDir: "tests",
2459
+ srcDir: ["src"],
2460
+ contextFilledCells: 0,
2461
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2462
+ }),
2463
+ );
2464
+ assert.equal(
2465
+ spawnSync("git", ["log", "-1", "--pretty=%s"], {
2466
+ cwd: gitFailure.projectBase,
2467
+ encoding: "utf8",
2468
+ }).stdout.trim(),
2469
+ "init",
2470
+ );
2471
+ } finally {
2472
+ process.chdir(previousCwd);
2473
+ fs.rmSync(gitFailure.projectBase, { recursive: true, force: true });
2474
+ }
2475
+
2476
+ const sourceFailure = initFixtureRepo({ fixtures: [] });
2477
+ try {
2478
+ const worktreeParentPath = path.dirname(sourceFailure.projectBase);
2479
+ const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
2480
+ const pi = createFakePi();
2481
+ piUsereqExtension(pi);
2482
+ const ctx = createFakeCtx(sourceFailure.projectBase);
2483
+ const recordedStatuses: string[] = [];
2484
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2485
+ ctx.ui.setStatus = (key: string, value?: string) => {
2486
+ if (key === "pi-usereq" && value !== undefined) {
2487
+ recordedStatuses.push(value);
2488
+ }
2489
+ originalSetStatus(key, value);
2490
+ };
2491
+
2492
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2493
+ await assert.rejects(
2494
+ pi.commands.get("req-references")!.handler("", ctx),
2495
+ /no source files found in configured directories/,
2496
+ );
2497
+
2498
+ const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
2499
+ .filter((entry) => !initialEntries.has(entry))
2500
+ .filter((entry) => /^PI-useReq-/.test(entry));
2501
+ assert.equal(pi.sentUserMessages.length, 0);
2502
+ assert.deepEqual(createdWorktreeEntries, []);
2503
+ assert.ok(ctx.__state.notifications.some((entry) => /no source files found in configured directories/.test(entry.message)));
2504
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
2505
+ assert.equal(
2506
+ ctx.__state.statuses.get("pi-usereq"),
2507
+ buildExpectedFakeStatusText({
2508
+ workflowState: "error",
2509
+ basePath: buildExpectedFakeBasePath(sourceFailure.projectBase),
2510
+ docsDir: DEFAULT_DOCS_DIR,
2511
+ testsDir: "tests",
2512
+ srcDir: ["src"],
2513
+ contextFilledCells: 0,
2514
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2515
+ }),
2516
+ );
2517
+ assert.equal(
2518
+ spawnSync("git", ["log", "-1", "--pretty=%s"], {
2519
+ cwd: sourceFailure.projectBase,
2520
+ encoding: "utf8",
2521
+ }).stdout.trim(),
2522
+ "init",
2523
+ );
2524
+ } finally {
2525
+ process.chdir(previousCwd);
2526
+ fs.rmSync(sourceFailure.projectBase, { recursive: true, force: true });
2527
+ }
2528
+ });
2529
+
2530
+ /**
2531
+ * @brief Verifies successful `req-reset` recovers from a non-`idle` worktree-backed prompt state.
2532
+ * @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree with workflow state `running`, appends one unique execution-session record, invokes `req-reset` from that non-`idle` state, and verifies transcript preservation, base-path restoration, generated worktree plus branch deletion, unchanged LLM prompt count, success notification, and final workflow state `idle`. Runtime is dominated by temporary git worktree setup plus session-file inspection. Side effects are limited to temporary repository mutation and fake UI notifications.
2533
+ * @return {Promise<void>} Promise resolved after recovery-path assertions complete.
2534
+ * @throws {AssertionError} Throws when `req-reset` rejects the non-`idle` state, loses the execution transcript, leaves generated cleanup artifacts behind, or dispatches an additional LLM prompt.
2535
+ * @satisfies TST-106
2536
+ */
2537
+ test("req-reset accepts non-idle workflow state, preserves transcript, and removes generated worktrees", async () => {
2538
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2539
+ const previousCwd = process.cwd();
2540
+ try {
2541
+ const pi = createFakePi();
2542
+ piUsereqExtension(pi);
2543
+ const ctx = createFakeCtx(projectBase);
2544
+
2545
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2546
+ const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2547
+ assert.notEqual(originalSessionFile, "");
2548
+
2549
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2550
+
2551
+ const sentMessageCountBeforeReset = pi.sentUserMessages.length;
2552
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2553
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2554
+ assert.ok(worktreeMatch, promptText);
2555
+ const worktreeName = worktreeMatch?.[1] ?? "";
2556
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2557
+ const executionSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2558
+ const appendedEntryId = "req-reset-transcript-entry";
2559
+
2560
+ assert.notEqual(executionSessionFile, "");
2561
+ assert.notEqual(executionSessionFile, originalSessionFile);
2562
+ assert.equal(ctx.cwd, executionBasePath);
2563
+ assert.equal(process.cwd(), executionBasePath);
2564
+ assert.equal(fs.existsSync(executionBasePath), true);
2565
+ assert.match(
2566
+ ctx.__state.statuses.get("pi-usereq") ?? "",
2567
+ /<accent>status:<\/accent><warning>running<\/warning>/,
2568
+ );
2569
+
2570
+ fs.appendFileSync(
2571
+ executionSessionFile,
2572
+ `${JSON.stringify({
2573
+ type: "custom",
2574
+ id: appendedEntryId,
2575
+ parentId: null,
2576
+ timestamp: new Date(0).toISOString(),
2577
+ customType: "req-reset-test",
2578
+ data: { recovered: true },
2579
+ })}\n`,
2580
+ "utf8",
2581
+ );
2582
+
2583
+ await pi.commands.get("req-reset")!.handler("", ctx);
2584
+
2585
+ assert.equal(pi.sentUserMessages.length, sentMessageCountBeforeReset);
2586
+ assert.equal(ctx.cwd, projectBase);
2587
+ assert.equal(process.cwd(), projectBase);
2588
+ assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
2589
+ assert.equal(fs.existsSync(executionBasePath), false);
2590
+ assert.equal(
2591
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2592
+ cwd: projectBase,
2593
+ encoding: "utf8",
2594
+ }).status,
2595
+ 1,
2596
+ );
2597
+ assert.match(fs.readFileSync(originalSessionFile, "utf8"), new RegExp(appendedEntryId));
2598
+ assert.ok(
2599
+ ctx.__state.notifications.some((entry) => /SUCCESS: req-reset/.test(entry.message)),
2600
+ );
2601
+ assert.equal(
2602
+ ctx.__state.statuses.get("pi-usereq"),
2603
+ buildExpectedFakeStatusText({
2604
+ workflowState: "idle",
2605
+ basePath: buildExpectedFakeBasePath(projectBase),
2606
+ docsDir: DEFAULT_DOCS_DIR,
2607
+ testsDir: "tests",
2608
+ srcDir: ["src"],
2609
+ contextFilledCells: 0,
2610
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2611
+ }),
2612
+ );
2613
+ } finally {
2614
+ process.chdir(previousCwd);
2615
+ fs.rmSync(projectBase, { recursive: true, force: true });
2616
+ }
2617
+ });
2618
+
2619
+ /**
2620
+ * @brief Verifies failing `req-reset` surfaces restoration errors without starting new worktree or LLM execution.
2621
+ * @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree, forces the subsequent base-path restoration switch to return `cancelled`, invokes `req-reset` from that non-`idle` state, and verifies the command reports the restoration failure, leaves the generated worktree plus branch in place for manual recovery, sends no additional LLM prompt, and transitions workflow state to `error`. Runtime is dominated by temporary git worktree setup. Side effects are limited to temporary repository mutation and fake UI notifications.
2622
+ * @return {Promise<void>} Promise resolved after failure-path assertions complete.
2623
+ * @throws {AssertionError} Throws when `req-reset` hides restoration failures, creates new cleanup artifacts, or clears the generated worktree plus branch despite the failed restore.
2624
+ * @satisfies TST-107
2625
+ */
2626
+ test("req-reset surfaces restoration failures and transitions workflow state to error", async () => {
2627
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2628
+ const previousCwd = process.cwd();
2629
+ try {
2630
+ const pi = createFakePi();
2631
+ piUsereqExtension(pi);
2632
+ const ctx = createFakeCtx(projectBase);
2633
+
2634
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2635
+ const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2636
+ assert.notEqual(originalSessionFile, "");
2637
+
2638
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2639
+
2640
+ const sentMessageCountBeforeReset = pi.sentUserMessages.length;
2641
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2642
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2643
+ assert.ok(worktreeMatch, promptText);
2644
+ const worktreeName = worktreeMatch?.[1] ?? "";
2645
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2646
+
2647
+ assert.match(
2648
+ ctx.__state.statuses.get("pi-usereq") ?? "",
2649
+ /<accent>status:<\/accent><warning>running<\/warning>/,
2650
+ );
2651
+
2652
+ ctx.switchSession = async (_sessionPath: string) => ({ cancelled: true });
2653
+
2654
+ await assert.rejects(
2655
+ pi.commands.get("req-reset")!.handler("", ctx),
2656
+ /Session switch cancelled/,
2657
+ );
2658
+
2659
+ assert.equal(pi.sentUserMessages.length, sentMessageCountBeforeReset);
2660
+ assert.equal(ctx.cwd, executionBasePath);
2661
+ assert.equal(process.cwd(), executionBasePath);
2662
+ assert.equal(fs.existsSync(executionBasePath), true);
2663
+ assert.equal(
2664
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2665
+ cwd: projectBase,
2666
+ encoding: "utf8",
2667
+ }).status,
2668
+ 0,
2669
+ );
2670
+ assert.ok(
2671
+ ctx.__state.notifications.some((entry) => /Session switch cancelled/.test(entry.message)),
2672
+ );
2673
+ assert.equal(
2674
+ ctx.__state.statuses.get("pi-usereq"),
2675
+ buildExpectedFakeStatusText({
2676
+ workflowState: "error",
2677
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2678
+ docsDir: DEFAULT_DOCS_DIR,
2679
+ testsDir: "tests",
2680
+ srcDir: ["src"],
2681
+ contextFilledCells: 0,
2682
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2683
+ }),
2684
+ );
2685
+ } finally {
2686
+ process.chdir(previousCwd);
2687
+ fs.rmSync(projectBase, { recursive: true, force: true });
2688
+ }
2689
+ });
2690
+
2691
+ test("prompt commands skip worktree creation when auto git commit is disabled", async () => {
2692
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2693
+ try {
2694
+ writeProjectConfigOverrides(projectBase, {
2695
+ AUTO_GIT_COMMIT: "disable",
2696
+ GIT_WORKTREE_ENABLED: "enable",
2697
+ });
2698
+ const pi = createFakePi();
2699
+ piUsereqExtension(pi);
2700
+ const command = pi.commands.get("req-change");
2701
+ assert.ok(command);
2702
+ const ctx = createFakeCtx(projectBase);
2703
+
2704
+ await command!.handler("Adjust docs", ctx);
2705
+
2706
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2707
+ const expectedBasePath = buildExpectedFakeBasePath(projectBase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2708
+ assert.doesNotMatch(promptText, /created worktree/);
2709
+ assert.match(
2710
+ promptText,
2711
+ new RegExp(`worktree orchestration is disabled and context-path remains \`${expectedBasePath}\``),
2712
+ );
2713
+ } finally {
2714
+ fs.rmSync(projectBase, { recursive: true, force: true });
2715
+ }
2716
+ });
2717
+
2718
+ test("prompt commands capture checking before handoff and running at prompt injection", async () => {
2719
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2720
+ const previousCwd = process.cwd();
2721
+ let checkingStatus = "";
2722
+ let handoffStatus = "";
2723
+ try {
2724
+ const pi = createFakePi();
2725
+ piUsereqExtension(pi);
2726
+ const ctx = createFakeCtx(projectBase);
2727
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2728
+ const originalSendUserMessage = pi.sendUserMessage.bind(pi);
2729
+ pi.sendUserMessage = (content: unknown, options?: unknown) => {
2730
+ handoffStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2731
+ originalSendUserMessage(content, options);
2732
+ };
2733
+ setPromptCommandPostCreateHookForTests(() => {
2734
+ checkingStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2735
+ });
2736
+
2737
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2738
+
2739
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2740
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2741
+ assert.ok(worktreeMatch, promptText);
2742
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2743
+ assert.equal(
2744
+ checkingStatus,
2745
+ buildExpectedFakeStatusText({
2746
+ workflowState: "checking",
2747
+ basePath: buildExpectedFakeBasePath(projectBase),
2748
+ docsDir: DEFAULT_DOCS_DIR,
2749
+ testsDir: "tests",
2750
+ srcDir: ["src"],
2751
+ contextFilledCells: 0,
2752
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2753
+ }),
2754
+ );
2755
+ assert.equal(
2756
+ handoffStatus,
2757
+ buildExpectedFakeStatusText({
2758
+ workflowState: "checking",
2759
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2760
+ docsDir: DEFAULT_DOCS_DIR,
2761
+ testsDir: "tests",
2762
+ srcDir: ["src"],
2763
+ contextFilledCells: 0,
2764
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2765
+ }),
2766
+ );
2767
+ assert.equal(
2768
+ ctx.__state.statuses.get("pi-usereq"),
2769
+ buildExpectedFakeStatusText({
2770
+ workflowState: "running",
2771
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2772
+ docsDir: DEFAULT_DOCS_DIR,
2773
+ testsDir: "tests",
2774
+ srcDir: ["src"],
2775
+ contextFilledCells: 0,
2776
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2777
+ }),
2778
+ );
2779
+
2780
+ await pi.emit("before_agent_start", {}, ctx);
2781
+ await pi.emit("agent_start", {}, ctx);
2782
+ await pi.emit("agent_end", {
2783
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2784
+ }, ctx);
2785
+ } finally {
2786
+ setPromptCommandPostCreateHookForTests(undefined);
2787
+ process.chdir(previousCwd);
2788
+ fs.rmSync(projectBase, { recursive: true, force: true });
2789
+ }
2790
+ });
2791
+
2792
+ /**
2793
+ * @brief Verifies every req command except `req-reset` rejects non-`idle` workflow state and transitions to `error`.
2794
+ * @details Starts one no-worktree `req-change` run until workflow state becomes `running`, invokes `req-analyze` and `req-references` while orchestration remains active, and verifies both handlers reject with deterministic diagnostics, emit error notifications, send no additional LLM prompt, and move the shared workflow state to `error` before `agent_end` restores `idle`. Runtime is dominated by temporary project setup plus fake lifecycle dispatch. Side effects are limited to temporary filesystem mutation and fake UI notifications.
2795
+ * @return {Promise<void>} Promise resolved after rejection-path assertions complete.
2796
+ * @throws {AssertionError} Throws when a busy req command starts new orchestration, skips the error transition, or prevents the active no-worktree prompt from returning to `idle`.
2797
+ * @satisfies TST-068
2798
+ */
2799
+ test("req commands except req-reset reject non-idle workflow state and transition to error", async () => {
2064
2800
  const { projectBase } = initFixtureRepo({ fixtures: [] });
2065
2801
  const previousCwd = process.cwd();
2066
- let checkingStatus = "";
2067
- let handoffStatus = "";
2068
2802
  try {
2803
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
2069
2804
  const pi = createFakePi();
2070
2805
  piUsereqExtension(pi);
2071
2806
  const ctx = createFakeCtx(projectBase);
2072
2807
  await pi.emit("session_start", { reason: "startup" }, ctx);
2073
- const originalSendUserMessage = pi.sendUserMessage.bind(pi);
2074
- pi.sendUserMessage = (content: unknown, options?: unknown) => {
2075
- handoffStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2076
- originalSendUserMessage(content, options);
2077
- };
2078
- setPromptCommandPostCreateHookForTests(() => {
2079
- checkingStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2080
- });
2081
2808
 
2082
2809
  await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2083
-
2084
- const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2085
- const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2086
- assert.ok(worktreeMatch, promptText);
2087
- const executionBasePath = worktreeMatch?.[2] ?? "";
2088
2810
  assert.equal(
2089
- checkingStatus,
2811
+ ctx.__state.statuses.get("pi-usereq"),
2090
2812
  buildExpectedFakeStatusText({
2091
- workflowState: "checking",
2813
+ workflowState: "running",
2092
2814
  basePath: buildExpectedFakeBasePath(projectBase),
2093
2815
  docsDir: DEFAULT_DOCS_DIR,
2094
2816
  testsDir: "tests",
@@ -2097,11 +2819,21 @@ test("prompt commands capture checking before handoff and running at prompt inje
2097
2819
  et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2098
2820
  }),
2099
2821
  );
2822
+
2823
+ await assert.rejects(
2824
+ pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2825
+ /expected idle/,
2826
+ );
2827
+ assert.equal(pi.sentUserMessages.length, 1);
2100
2828
  assert.equal(
2101
- handoffStatus,
2829
+ ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
2830
+ 1,
2831
+ );
2832
+ assert.equal(
2833
+ ctx.__state.statuses.get("pi-usereq"),
2102
2834
  buildExpectedFakeStatusText({
2103
- workflowState: "checking",
2104
- basePath: buildExpectedFakeBasePath(executionBasePath),
2835
+ workflowState: "error",
2836
+ basePath: buildExpectedFakeBasePath(projectBase),
2105
2837
  docsDir: DEFAULT_DOCS_DIR,
2106
2838
  testsDir: "tests",
2107
2839
  srcDir: ["src"],
@@ -2109,11 +2841,21 @@ test("prompt commands capture checking before handoff and running at prompt inje
2109
2841
  et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2110
2842
  }),
2111
2843
  );
2844
+
2845
+ await assert.rejects(
2846
+ pi.commands.get("req-references")!.handler("", ctx),
2847
+ /expected idle/,
2848
+ );
2849
+ assert.equal(pi.sentUserMessages.length, 1);
2850
+ assert.equal(
2851
+ ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
2852
+ 2,
2853
+ );
2112
2854
  assert.equal(
2113
2855
  ctx.__state.statuses.get("pi-usereq"),
2114
2856
  buildExpectedFakeStatusText({
2115
- workflowState: "running",
2116
- basePath: buildExpectedFakeBasePath(executionBasePath),
2857
+ workflowState: "error",
2858
+ basePath: buildExpectedFakeBasePath(projectBase),
2117
2859
  docsDir: DEFAULT_DOCS_DIR,
2118
2860
  testsDir: "tests",
2119
2861
  srcDir: ["src"],
@@ -2122,33 +2864,64 @@ test("prompt commands capture checking before handoff and running at prompt inje
2122
2864
  }),
2123
2865
  );
2124
2866
 
2125
- await pi.emit("before_agent_start", {}, ctx);
2126
2867
  await pi.emit("agent_start", {}, ctx);
2127
2868
  await pi.emit("agent_end", {
2128
2869
  messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2129
2870
  }, ctx);
2871
+ assert.equal(
2872
+ ctx.__state.statuses.get("pi-usereq"),
2873
+ buildExpectedFakeStatusText({
2874
+ workflowState: "idle",
2875
+ basePath: buildExpectedFakeBasePath(projectBase),
2876
+ docsDir: DEFAULT_DOCS_DIR,
2877
+ testsDir: "tests",
2878
+ srcDir: ["src"],
2879
+ contextFilledCells: 0,
2880
+ et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
2881
+ }),
2882
+ );
2130
2883
  } finally {
2131
- setPromptCommandPostCreateHookForTests(undefined);
2132
2884
  process.chdir(previousCwd);
2133
2885
  fs.rmSync(projectBase, { recursive: true, force: true });
2134
2886
  }
2135
2887
  });
2136
2888
 
2137
- test("prompt commands reject non-idle workflow state before starting a new orchestration", async () => {
2889
+ /**
2890
+ * @brief Verifies a worktree-backed prompt still finalizes after a busy req-command rejection moves workflow state to `error`.
2891
+ * @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree with workflow state `running`, invokes `req-analyze` while the active run is still in progress so the busy-command guard forces workflow state to `error`, then emits the matched lifecycle completion and verifies `base-path` restoration, worktree plus branch deletion, unchanged prompt count, and final workflow state `idle`. Runtime is dominated by temporary git worktree setup plus fake lifecycle dispatch. Side effects are limited to temporary repository mutation and fake UI notifications.
2892
+ * @return {Promise<void>} Promise resolved after finalization-path assertions complete.
2893
+ * @throws {AssertionError} Throws when busy-command rejection orphaned the successful worktree-backed run or prevented cleanup.
2894
+ * @satisfies TST-068, TST-082
2895
+ */
2896
+ test("worktree-backed prompt finalization still succeeds after a busy-command rejection", async () => {
2138
2897
  const { projectBase } = initFixtureRepo({ fixtures: [] });
2898
+ const previousCwd = process.cwd();
2139
2899
  try {
2140
- writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
2141
2900
  const pi = createFakePi();
2142
2901
  piUsereqExtension(pi);
2143
2902
  const ctx = createFakeCtx(projectBase);
2903
+
2144
2904
  await pi.emit("session_start", { reason: "startup" }, ctx);
2905
+ const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2906
+ assert.notEqual(originalSessionFile, "");
2145
2907
 
2146
2908
  await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2909
+
2910
+ const sentMessageCount = pi.sentUserMessages.length;
2911
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2912
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2913
+ assert.ok(worktreeMatch, promptText);
2914
+ const worktreeName = worktreeMatch?.[1] ?? "";
2915
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2916
+
2917
+ assert.equal(ctx.cwd, executionBasePath);
2918
+ assert.equal(process.cwd(), executionBasePath);
2919
+ assert.equal(fs.existsSync(executionBasePath), true);
2147
2920
  assert.equal(
2148
2921
  ctx.__state.statuses.get("pi-usereq"),
2149
2922
  buildExpectedFakeStatusText({
2150
2923
  workflowState: "running",
2151
- basePath: buildExpectedFakeBasePath(projectBase),
2924
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2152
2925
  docsDir: DEFAULT_DOCS_DIR,
2153
2926
  testsDir: "tests",
2154
2927
  srcDir: ["src"],
@@ -2161,13 +2934,16 @@ test("prompt commands reject non-idle workflow state before starting a new orche
2161
2934
  pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2162
2935
  /expected idle/,
2163
2936
  );
2164
- assert.equal(pi.sentUserMessages.length, 1);
2165
- assert.ok(ctx.__state.notifications.some((entry) => /expected idle/.test(entry.message)));
2937
+ assert.equal(pi.sentUserMessages.length, sentMessageCount);
2938
+ assert.equal(
2939
+ ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
2940
+ 1,
2941
+ );
2166
2942
  assert.equal(
2167
2943
  ctx.__state.statuses.get("pi-usereq"),
2168
2944
  buildExpectedFakeStatusText({
2169
- workflowState: "running",
2170
- basePath: buildExpectedFakeBasePath(projectBase),
2945
+ workflowState: "error",
2946
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2171
2947
  docsDir: DEFAULT_DOCS_DIR,
2172
2948
  testsDir: "tests",
2173
2949
  srcDir: ["src"],
@@ -2176,10 +2952,30 @@ test("prompt commands reject non-idle workflow state before starting a new orche
2176
2952
  }),
2177
2953
  );
2178
2954
 
2955
+ await pi.emit("before_agent_start", {}, ctx);
2179
2956
  await pi.emit("agent_start", {}, ctx);
2180
2957
  await pi.emit("agent_end", {
2181
2958
  messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2182
2959
  }, ctx);
2960
+
2961
+ assert.equal(ctx.cwd, projectBase);
2962
+ assert.equal(process.cwd(), projectBase);
2963
+ assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
2964
+ assert.equal(fs.existsSync(executionBasePath), false);
2965
+ assert.notEqual(
2966
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2967
+ cwd: projectBase,
2968
+ encoding: "utf8",
2969
+ }).status,
2970
+ 0,
2971
+ );
2972
+ assert.equal(
2973
+ spawnSync("git", ["status", "--porcelain"], {
2974
+ cwd: projectBase,
2975
+ encoding: "utf8",
2976
+ }).stdout.trim(),
2977
+ "",
2978
+ );
2183
2979
  assert.equal(
2184
2980
  ctx.__state.statuses.get("pi-usereq"),
2185
2981
  buildExpectedFakeStatusText({
@@ -2193,6 +2989,7 @@ test("prompt commands reject non-idle workflow state before starting a new orche
2193
2989
  }),
2194
2990
  );
2195
2991
  } finally {
2992
+ process.chdir(previousCwd);
2196
2993
  fs.rmSync(projectBase, { recursive: true, force: true });
2197
2994
  }
2198
2995
  });
@@ -2343,6 +3140,110 @@ test("worktree-enabled prompt commands merge successful runs, restore base-path,
2343
3140
  }
2344
3141
  });
2345
3142
 
3143
+ /**
3144
+ * @brief Verifies successful closure stashes tracked `base-path` changes before merge and restores them afterward.
3145
+ * @details Creates tracked `base-path` files, dirties one in the index and one in the worktree after worktree handoff, commits a worktree change, and proves prompt finalization runs `git stash`/merge/`git stash pop` semantics by merging successfully, restoring the local `base-path` changes, deleting the worktree, and surfacing a warning-only notification about the dirty restored base path.
3146
+ * @return {Promise<void>} Promise resolved after stash-assisted merge assertions complete.
3147
+ * @throws {AssertionError} Throws when finalization drops local `base-path` changes, skips the merge, or reports an error instead of a warning.
3148
+ * @satisfies REQ-208, REQ-291, REQ-292
3149
+ */
3150
+ test("worktree-backed successful closure stashes tracked base-path changes, merges, restores changes, and warns about dirty base-path", async () => {
3151
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3152
+ const previousCwd = process.cwd();
3153
+ try {
3154
+ const stagedBasePath = path.join(projectBase, "staged-base.txt");
3155
+ const worktreeBasePath = path.join(projectBase, "worktree-base.txt");
3156
+ fs.writeFileSync(stagedBasePath, "staged base clean\n", "utf8");
3157
+ fs.writeFileSync(worktreeBasePath, "worktree base clean\n", "utf8");
3158
+ assert.equal(
3159
+ spawnSync("git", ["add", "staged-base.txt", "worktree-base.txt"], {
3160
+ cwd: projectBase,
3161
+ encoding: "utf8",
3162
+ }).status,
3163
+ 0,
3164
+ );
3165
+ const baseCommit = spawnSync("git", ["commit", "-m", "tracked base files"], {
3166
+ cwd: projectBase,
3167
+ encoding: "utf8",
3168
+ });
3169
+ assert.equal(baseCommit.status, 0, baseCommit.stderr);
3170
+
3171
+ const pi = createFakePi();
3172
+ piUsereqExtension(pi);
3173
+ const ctx = createFakeCtx(projectBase);
3174
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3175
+
3176
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3177
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3178
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3179
+ assert.ok(worktreeMatch, promptText);
3180
+ const worktreeName = worktreeMatch?.[1] ?? "";
3181
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3182
+
3183
+ await pi.emit("before_agent_start", {}, ctx);
3184
+ await pi.emit("agent_start", {}, ctx);
3185
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3186
+ fs.writeFileSync(path.join(executionBasePath, "src", "stash-assisted.ts"), "export const STASH_ASSISTED = 1;\n", "utf8");
3187
+ assert.equal(
3188
+ spawnSync("git", ["add", "src/stash-assisted.ts"], {
3189
+ cwd: executionBasePath,
3190
+ encoding: "utf8",
3191
+ }).status,
3192
+ 0,
3193
+ );
3194
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "stash assisted change"], {
3195
+ cwd: executionBasePath,
3196
+ encoding: "utf8",
3197
+ });
3198
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3199
+
3200
+ fs.writeFileSync(stagedBasePath, "staged base dirty\n", "utf8");
3201
+ assert.equal(
3202
+ spawnSync("git", ["add", "staged-base.txt"], {
3203
+ cwd: projectBase,
3204
+ encoding: "utf8",
3205
+ }).status,
3206
+ 0,
3207
+ );
3208
+ fs.writeFileSync(worktreeBasePath, "worktree base dirty\n", "utf8");
3209
+
3210
+ await pi.emit("agent_end", {
3211
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3212
+ }, ctx);
3213
+
3214
+ const statusResult = spawnSync("git", ["status", "--porcelain"], {
3215
+ cwd: projectBase,
3216
+ encoding: "utf8",
3217
+ });
3218
+ assert.equal(statusResult.status, 0, statusResult.stderr);
3219
+ assert.equal(process.cwd(), projectBase);
3220
+ assert.equal(ctx.cwd, projectBase);
3221
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "stash-assisted.ts")));
3222
+ assert.equal(fs.readFileSync(stagedBasePath, "utf8"), "staged base dirty\n");
3223
+ assert.equal(fs.readFileSync(worktreeBasePath, "utf8"), "worktree base dirty\n");
3224
+ assert.match(statusResult.stdout, /staged-base\.txt/);
3225
+ assert.match(statusResult.stdout, /worktree-base\.txt/);
3226
+ assert.equal(fs.existsSync(executionBasePath), false);
3227
+ assert.notEqual(
3228
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3229
+ cwd: projectBase,
3230
+ encoding: "utf8",
3231
+ }).status,
3232
+ 0,
3233
+ );
3234
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3235
+ assert.ok(
3236
+ ctx.__state.notifications.some(
3237
+ (entry) => entry.level === "info"
3238
+ && /WARNING: Restored base-path changes after merge; base-path is not clean\./.test(entry.message),
3239
+ ),
3240
+ );
3241
+ } finally {
3242
+ process.chdir(previousCwd);
3243
+ fs.rmSync(projectBase, { recursive: true, force: true });
3244
+ }
3245
+ });
3246
+
2346
3247
  /**
2347
3248
  * @brief Verifies prompt-command bootstrap ignores stale deleted-worktree session metadata on later commands.
2348
3249
  * @details Simulates the `/tmp/debug.json` follow-up failure mode: one successful worktree-backed `req-change` run restores `base-path` and deletes its worktree, then a later `req-change` invocation receives a stale command context whose `cwd` and session getters still point at the removed execution worktree. The test proves command bootstrap MUST discard deleted-worktree cwd/session residue, prepare a fresh worktree with a new identifier, and complete the second successful run without reusing the removed execution path.
@@ -3939,17 +4840,11 @@ test("prompt commands remain functional when custom tool registrations are remov
3939
4840
  test("session_start applies configured pi-usereq startup tools", async () => {
3940
4841
  const cwd = createTempDir("pi-usereq-tools-startup-");
3941
4842
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
3942
- fs.writeFileSync(
3943
- getProjectConfigPath(cwd),
3944
- `${JSON.stringify({
3945
- "docs-dir": DEFAULT_DOCS_DIR,
3946
- "tests-dir": "tests",
3947
- "src-dir": ["src", "foobar"],
3948
- "static-check": {},
3949
- "enabled-tools": ["files-tokens", "static-check"],
3950
- }, null, 2)}\n`,
3951
- "utf8",
3952
- );
4843
+ saveFixtureConfigs(cwd, {
4844
+ ...getDefaultConfig(cwd),
4845
+ "src-dir": ["src", "foobar"],
4846
+ "enabled-tools": ["files-tokens", "static-check"],
4847
+ });
3953
4848
 
3954
4849
  const pi = createFakePi();
3955
4850
  piUsereqExtension(pi);
@@ -3996,6 +4891,58 @@ test("session_start renders the 0-percent context icon when context usage is emp
3996
4891
  );
3997
4892
  });
3998
4893
 
4894
+ /**
4895
+ * @brief Verifies direct status renders refresh context usage before drawing the footer.
4896
+ * @details Exercises `renderPiUsereqStatus(...)` without going through an intercepted lifecycle hook, then mutates the fake `getContextUsage()` result to simulate a post-compaction context reset. The test proves direct rerender call sites must fetch fresh context usage so the `context:` gauge updates immediately instead of reusing stale controller state.
4897
+ * @return {void} No return value.
4898
+ * @throws {AssertionError} Throws when a direct render ignores the live `getContextUsage()` snapshot or fails to clear the gauge after context reset.
4899
+ * @satisfies REQ-118, REQ-119, REQ-122, REQ-284
4900
+ */
4901
+ test("direct status rerenders refresh the context gauge after context reset", () => {
4902
+ const cwd = createTempDir("pi-usereq-context-direct-render-");
4903
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4904
+ const controller = createPiUsereqStatusController();
4905
+ setPiUsereqStatusConfig(controller, getDefaultConfig(cwd));
4906
+ const contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } = {
4907
+ tokens: 910,
4908
+ contextWindow: 1000,
4909
+ percent: 91,
4910
+ };
4911
+ const ctx = createFakeCtx(cwd, { selects: [] }, {
4912
+ getContextUsage: () => ({ ...contextUsage }),
4913
+ });
4914
+
4915
+ renderPiUsereqStatus(controller, ctx);
4916
+ assert.equal(
4917
+ ctx.__state.statuses.get("pi-usereq"),
4918
+ buildExpectedFakeStatusText({
4919
+ basePath: buildExpectedFakeBasePath(cwd),
4920
+ docsDir: DEFAULT_DOCS_DIR,
4921
+ testsDir: "tests",
4922
+ srcDir: ["src"],
4923
+ contextFilledCells: 10,
4924
+ contextPercent: 91,
4925
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4926
+ }),
4927
+ );
4928
+
4929
+ contextUsage.tokens = null;
4930
+ contextUsage.percent = null;
4931
+ renderPiUsereqStatus(controller, ctx);
4932
+ assert.equal(
4933
+ ctx.__state.statuses.get("pi-usereq"),
4934
+ buildExpectedFakeStatusText({
4935
+ basePath: buildExpectedFakeBasePath(cwd),
4936
+ docsDir: DEFAULT_DOCS_DIR,
4937
+ testsDir: "tests",
4938
+ srcDir: ["src"],
4939
+ contextFilledCells: 0,
4940
+ contextPercent: null,
4941
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4942
+ }),
4943
+ );
4944
+ });
4945
+
3999
4946
  /**
4000
4947
  * @brief Verifies stale replacement contexts do not break workflow-state rendering.
4001
4948
  * @details Simulates the guarded pi runtime error thrown when an extension context is accessed after session replacement or reload. The test proves workflow-state updates must preserve internal state while suppressing stale-context render failures instead of surfacing extension errors.
@@ -4007,7 +4954,7 @@ test("stale replacement contexts do not break workflow-state rendering", () => {
4007
4954
  const cwd = createTempDir("pi-usereq-stale-render-");
4008
4955
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4009
4956
  const controller = createPiUsereqStatusController();
4010
- setPiUsereqStatusConfig(controller, getDefaultConfig());
4957
+ setPiUsereqStatusConfig(controller, getDefaultConfig(cwd));
4011
4958
  const staleCtx = createFakeCtx(cwd);
4012
4959
  Object.defineProperty(staleCtx, "ui", {
4013
4960
  configurable: true,
@@ -4092,12 +5039,12 @@ test("context hook refreshes context usage and rounds progress cells upward", as
4092
5039
 
4093
5040
  /**
4094
5041
  * @brief Verifies context-gauge icon cutovers below the error range.
4095
- * @details Replays deterministic context percentages at documented threshold boundaries below `90%` so the status bar proves the `▕▂▏`, `▕▄▏`, `▕▆▏`, and `▕█▏` transitions while keeping the default-color contract intact. Runtime is O(n) in case count. Side effects are limited to in-memory status updates.
5042
+ * @details Replays deterministic context percentages at documented threshold boundaries below `90%` so the status bar proves the `▕▂▏`, `▕▄▏`, `▕▆▏`, and `▕█▏` transitions while rendering every non-error band with the same warning-token color used by the `status` value. Runtime is O(n) in case count. Side effects are limited to in-memory status updates.
4096
5043
  * @return {Promise<void>} Promise resolved after all threshold assertions complete.
4097
- * @throws {AssertionError} Throws when any threshold renders the wrong icon.
4098
- * @satisfies TST-096
5044
+ * @throws {AssertionError} Throws when any threshold renders the wrong icon or low-band color.
5045
+ * @satisfies REQ-122, REQ-126, TST-096
4099
5046
  */
4100
- test("context hook applies quarter-threshold gauge icons below the error range", async () => {
5047
+ test("context hook applies warning-colored quarter-threshold gauge icons below the error range", async () => {
4101
5048
  const cwd = createTempDir("pi-usereq-context-threshold-status-");
4102
5049
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4103
5050
  const contextUsage = { tokens: 0, contextWindow: 1000, percent: 0 };
@@ -4388,7 +5335,9 @@ test("session_start enables default custom tools and default embedded tools only
4388
5335
  });
4389
5336
 
4390
5337
  test("default configuration applies the documented static-check, debug, notify, sound, and pushover defaults", () => {
4391
- const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
5338
+ const cwd = createTempDir("pi-usereq-default-notify-");
5339
+ const config = getDefaultConfig(cwd);
5340
+ saveFixtureConfigs(cwd, config);
4392
5341
 
4393
5342
  assert.equal(config.AUTO_GIT_COMMIT, "enable");
4394
5343
  assert.deepEqual(Object.keys(config["static-check"]).length, 20);
@@ -4455,6 +5404,21 @@ test("default configuration applies the documented static-check, debug, notify,
4455
5404
  assert.equal(config["notify-pushover-on-failed"], false);
4456
5405
  assert.equal(config.PI_NOTIFY_CMD, 'notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a "PI-useReq" "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"');
4457
5406
  assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
5407
+
5408
+ const localConfig = readProjectConfigJson(cwd);
5409
+ const globalConfig = readGlobalConfigJson();
5410
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "enable");
5411
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "disable");
5412
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
5413
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5414
+ { module: "Command", cmd: "ruff", params: ["check"] },
5415
+ ]);
5416
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, []);
5417
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "enable");
5418
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "enable");
5419
+ assert.equal(globalConfig["notify-enabled"], false);
5420
+ assert.equal(Object.hasOwn(localConfig, "notify-enabled"), false);
5421
+ assert.equal(Object.hasOwn(localConfig, "enabled-tools"), false);
4458
5422
  });
4459
5423
 
4460
5424
  test("configuration menu orders tool toggles and can enable embedded builtin tools", async () => {
@@ -4470,17 +5434,18 @@ test("configuration menu orders tool toggles and can enable embedded builtin too
4470
5434
 
4471
5435
  await command!.handler("", ctx);
4472
5436
 
4473
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5437
+ const config = readGlobalConfigJson();
4474
5438
  assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
4475
5439
  "compress",
4476
5440
  "references",
4477
5441
  "search",
4478
5442
  "static-check",
5443
+ "summarize",
4479
5444
  "tokens",
4480
5445
  "files-compress",
4481
- "files-references",
4482
5446
  "files-search",
4483
5447
  "files-static-check",
5448
+ "files-summarize",
4484
5449
  "files-tokens",
4485
5450
  "bash",
4486
5451
  "edit",
@@ -4510,12 +5475,12 @@ test("configuration menu can disable configurable active tools", async () => {
4510
5475
  }),
4511
5476
  );
4512
5477
 
4513
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5478
+ const config = readGlobalConfigJson();
4514
5479
  assert.deepEqual(config["enabled-tools"], []);
4515
5480
  assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
4516
5481
  });
4517
5482
 
4518
- test("configuration menu can persist notify and sound settings", async () => {
5483
+ test("configuration menu can persist notify and boot-sound settings without changing runtime sound", async () => {
4519
5484
  const cwd = createTempDir("pi-usereq-menu-notify-");
4520
5485
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4521
5486
  const pi = createFakePi();
@@ -4529,7 +5494,7 @@ test("configuration menu can persist notify and sound settings", async () => {
4529
5494
  "Notification events",
4530
5495
  "Prompt failed",
4531
5496
  "Save and close",
4532
- "Enable sound",
5497
+ "Enable sound (boot value)",
4533
5498
  "high",
4534
5499
  "Sound events",
4535
5500
  "Prompt interrupted",
@@ -4551,9 +5516,10 @@ test("configuration menu can persist notify and sound settings", async () => {
4551
5516
  ],
4552
5517
  });
4553
5518
 
5519
+ await pi.emit("session_start", { reason: "startup" }, ctx);
4554
5520
  await command!.handler("", ctx);
4555
5521
 
4556
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5522
+ const config = readGlobalConfigJson();
4557
5523
  assert.equal(config["notify-enabled"], true);
4558
5524
  assert.equal(config["notify-on-completed"], true);
4559
5525
  assert.equal(config["notify-on-interrupted"], false);
@@ -4576,7 +5542,7 @@ test("configuration menu can persist notify and sound settings", async () => {
4576
5542
  srcDir: ["src"],
4577
5543
  contextFilledCells: 0,
4578
5544
  et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4579
- sound: "high",
5545
+ sound: "none",
4580
5546
  }),
4581
5547
  );
4582
5548
  });
@@ -4614,7 +5580,7 @@ test("configuration menu can persist pushover settings", async () => {
4614
5580
 
4615
5581
  await command!.handler("", ctx);
4616
5582
 
4617
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5583
+ const config = readGlobalConfigJson();
4618
5584
  assert.equal(config["notify-pushover-enabled"], true);
4619
5585
  assert.equal(config["notify-pushover-on-completed"], true);
4620
5586
  assert.equal(config["notify-pushover-on-interrupted"], false);
@@ -4641,14 +5607,10 @@ test("configuration menu can persist pushover settings", async () => {
4641
5607
  test("notifications menu escapes pushover text and locks pushover enablement until credentials exist", async () => {
4642
5608
  const cwd = createTempDir("pi-usereq-menu-pushover-display-");
4643
5609
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4644
- fs.writeFileSync(
4645
- getProjectConfigPath(cwd),
4646
- `${JSON.stringify({
4647
- ...getDefaultConfig(cwd),
4648
- "notify-pushover-text": "first line\nsecond line",
4649
- }, null, 2)}\n`,
4650
- "utf8",
4651
- );
5610
+ saveFixtureConfigs(cwd, {
5611
+ ...getDefaultConfig(cwd),
5612
+ "notify-pushover-text": "first line\nsecond line",
5613
+ });
4652
5614
  const pi = createFakePi();
4653
5615
  piUsereqExtension(pi);
4654
5616
  const command = pi.commands.get("pi-usereq");
@@ -4662,7 +5624,7 @@ test("notifications menu escapes pushover text and locks pushover enablement unt
4662
5624
  const renderedNotificationsMenu = (ctx.__state.customRenderLines[2] ?? []).join("\n");
4663
5625
  assert.match(renderedNotificationsMenu, /first line\\nsecond line/);
4664
5626
  assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
4665
- assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
5627
+ assert.match(renderedNotificationsMenu, /<dim>configure user\/token keys first<\/dim>/);
4666
5628
  });
4667
5629
 
4668
5630
  test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
@@ -4874,7 +5836,7 @@ test("pushover global enable controls status and suppresses delivery when disabl
4874
5836
  await command!.handler("", ctx);
4875
5837
  writeProjectConfigOverrides(cwd, { GIT_WORKTREE_ENABLED: "disable" });
4876
5838
 
4877
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5839
+ const config = readGlobalConfigJson();
4878
5840
  assert.equal(config["notify-pushover-enabled"], false);
4879
5841
  assert.equal(config["notify-pushover-on-completed"], true);
4880
5842
  assert.equal(
@@ -5125,9 +6087,12 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
5125
6087
  }
5126
6088
  });
5127
6089
 
5128
- test("sound toggle shortcut cycles persisted pi-notify sound levels", async () => {
6090
+ test("sound toggle shortcut cycles active runtime pi-notify sound levels without persisting boot config", async () => {
5129
6091
  const cwd = createTempDir("pi-usereq-shortcut-notify-");
5130
6092
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6093
+ const persistedConfig = getDefaultConfig(cwd);
6094
+ saveFixtureConfigs(cwd, persistedConfig);
6095
+ const persistedGlobalConfigText = fs.readFileSync(getGlobalConfigPath(), "utf8");
5131
6096
  const previousCwd = process.cwd();
5132
6097
  const pi = createFakePi();
5133
6098
  process.chdir(cwd);
@@ -5144,8 +6109,7 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
5144
6109
 
5145
6110
  for (const expectedSound of ["low", "mid", "high", "none"]) {
5146
6111
  await shortcut!.handler(ctx);
5147
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5148
- assert.equal(config["notify-sound"], expectedSound);
6112
+ assert.equal(fs.readFileSync(getGlobalConfigPath(), "utf8"), persistedGlobalConfigText);
5149
6113
  assert.equal(
5150
6114
  ctx.__state.statuses.get("pi-usereq"),
5151
6115
  buildExpectedFakeStatusText({
@@ -5161,6 +6125,38 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
5161
6125
  }
5162
6126
  });
5163
6127
 
6128
+ test("session_start loads the active runtime sound from persisted boot config", async () => {
6129
+ const cwd = createTempDir("pi-usereq-session-start-sound-");
6130
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6131
+ const persistedConfig = getDefaultConfig(cwd);
6132
+ persistedConfig["notify-sound"] = "mid";
6133
+ saveFixtureConfigs(cwd, persistedConfig);
6134
+ const previousCwd = process.cwd();
6135
+ const pi = createFakePi();
6136
+ process.chdir(cwd);
6137
+ try {
6138
+ piUsereqExtension(pi);
6139
+ } finally {
6140
+ process.chdir(previousCwd);
6141
+ }
6142
+
6143
+ const ctx = createFakeCtx(cwd);
6144
+ await pi.emit("session_start", { reason: "startup" }, ctx);
6145
+
6146
+ assert.equal(
6147
+ ctx.__state.statuses.get("pi-usereq"),
6148
+ buildExpectedFakeStatusText({
6149
+ basePath: buildExpectedFakeBasePath(cwd),
6150
+ docsDir: DEFAULT_DOCS_DIR,
6151
+ testsDir: "tests",
6152
+ srcDir: ["src"],
6153
+ contextFilledCells: 0,
6154
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
6155
+ sound: "mid",
6156
+ }),
6157
+ );
6158
+ });
6159
+
5164
6160
  test("static-check exposes the supported programming languages", () => {
5165
6161
  const supported = getSupportedStaticCheckLanguageSupport();
5166
6162
  assert.equal(supported.length, 20);
@@ -5233,9 +6229,10 @@ test("configuration menu can toggle per-language static-check enablement", async
5233
6229
  }),
5234
6230
  );
5235
6231
 
5236
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5237
- assert.equal(config["static-check"].Python.enabled, "disable");
5238
- assert.deepEqual(config["static-check"].Python.checkers, [
6232
+ const localConfig = readProjectConfigJson(cwd);
6233
+ const globalConfig = readGlobalConfigJson();
6234
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "disable");
6235
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
5239
6236
  { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5240
6237
  { module: "Command", cmd: "ruff", params: ["check"] },
5241
6238
  ]);
@@ -5263,8 +6260,10 @@ test("configuration menu can add guided static-check entries for explicit suppor
5263
6260
  }),
5264
6261
  );
5265
6262
 
5266
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5267
- assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([
6263
+ const localConfig = readProjectConfigJson(cwd);
6264
+ const globalConfig = readGlobalConfigJson();
6265
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "enable");
6266
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, [
5268
6267
  { module: "Command", cmd: "true" },
5269
- ], "enable"));
6268
+ ]);
5270
6269
  });