pi-usereq 0.12.0 → 0.32.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.
@@ -16,6 +16,7 @@ import {
16
16
  createStaticCheckLanguageConfig,
17
17
  DEFAULT_DOCS_DIR,
18
18
  getDefaultConfig,
19
+ getGlobalConfigPath,
19
20
  getProjectConfigPath,
20
21
  loadConfig,
21
22
  } from "../src/core/config.js";
@@ -23,6 +24,7 @@ import {
23
24
  DEBUG_PROMPT_NAMES,
24
25
  DEFAULT_DEBUG_LOG_FILE,
25
26
  DEFAULT_DEBUG_LOG_ON_STATUS,
27
+ DEFAULT_DEBUG_TOOL_COMMANDS_ENABLED,
26
28
  DEFAULT_DEBUG_WORKFLOW_EVENTS,
27
29
  } from "../src/core/debug-runtime.js";
28
30
  import { PROMPT_COMMAND_NAMES } from "../src/core/prompt-command-catalog.js";
@@ -58,7 +60,13 @@ import {
58
60
  } from "../src/core/extension-status.js";
59
61
  import { showPiUsereqSettingsMenu } from "../src/core/settings-menu.js";
60
62
  import { getSupportedStaticCheckLanguageSupport } from "../src/core/static-check.js";
61
- import { createTempDir, initFixtureRepo } from "./helpers.js";
63
+ import {
64
+ createTempDir,
65
+ initFixtureRepo,
66
+ readGlobalConfigJson,
67
+ readProjectConfigJson,
68
+ saveFixtureConfigs,
69
+ } from "./helpers.js";
62
70
 
63
71
  /**
64
72
  * @brief Describes one fake extension command registration captured during tests.
@@ -91,17 +99,16 @@ type RegisteredTool = {
91
99
  type RegisteredShortcut = { description?: string; handler: (ctx: any) => Promise<void> | void };
92
100
 
93
101
  /**
94
- * @brief Applies targeted `.pi-usereq.json` overrides inside one test project.
95
- * @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.
102
+ * @brief Applies targeted effective-config overrides inside one test project.
103
+ * @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.
96
104
  * @param[in] projectBase {string} Fixture project root.
97
105
  * @param[in] overrides {Record<string, unknown>} Top-level config overrides.
98
106
  * @return {void} No return value.
99
107
  */
100
108
  function writeProjectConfigOverrides(projectBase: string, overrides: Record<string, unknown>): void {
101
- const configPath = getProjectConfigPath(projectBase);
102
- const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
109
+ const config = loadConfig(projectBase);
103
110
  Object.assign(config, overrides);
104
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
111
+ saveFixtureConfigs(projectBase, config);
105
112
  if (fs.existsSync(path.join(projectBase, ".git"))) {
106
113
  const gitEnv = {
107
114
  ...process.env,
@@ -519,13 +526,13 @@ function buildExpectedFakeBasePath(cwd: string): string {
519
526
  }
520
527
 
521
528
  /**
522
- * @brief Formats one expected home-relative base path for notify-template assertions.
523
- * @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.
524
- * @param[in] cwd {string} Runtime working directory.
525
- * @return {string} Home-relative or absolute base path used by notification templates.
529
+ * @brief Formats one expected home-relative display path.
530
+ * @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.
531
+ * @param[in] targetPath {string} Absolute or relative path candidate.
532
+ * @return {string} Home-relative or absolute display path.
526
533
  */
527
- function buildExpectedNotifyBasePath(cwd: string): string {
528
- const resolvedPath = path.resolve(cwd);
534
+ function buildExpectedDisplayPath(targetPath: string): string {
535
+ const resolvedPath = path.resolve(targetPath);
529
536
  const homePath = path.resolve(process.env.HOME ?? os.homedir());
530
537
  if (resolvedPath === homePath) {
531
538
  return "~";
@@ -538,15 +545,32 @@ function buildExpectedNotifyBasePath(cwd: string): string {
538
545
  }
539
546
 
540
547
  /**
541
- * @brief Formats the expected `Show configuration` path value.
542
- * @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.
548
+ * @brief Formats one expected home-relative base path for notify-template assertions.
549
+ * @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.
543
550
  * @param[in] cwd {string} Runtime working directory.
544
- * @return {string} `~`-relative or absolute config-path display string.
551
+ * @return {string} Home-relative or absolute base path used by notification templates.
552
+ */
553
+ function buildExpectedNotifyBasePath(cwd: string): string {
554
+ return buildExpectedDisplayPath(cwd);
555
+ }
556
+
557
+ /**
558
+ * @brief Formats the expected `Show local configuration` path value.
559
+ * @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.
560
+ * @param[in] cwd {string} Runtime working directory.
561
+ * @return {string} `~`-relative or absolute local config-path display string.
562
+ */
563
+ function buildExpectedShowLocalConfigPath(cwd: string): string {
564
+ return buildExpectedDisplayPath(getProjectConfigPath(cwd));
565
+ }
566
+
567
+ /**
568
+ * @brief Formats the expected `Show global configuration` path value.
569
+ * @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.
570
+ * @return {string} `~`-relative or absolute global config-path display string.
545
571
  */
546
- function buildExpectedShowConfigPath(cwd: string): string {
547
- return buildExpectedNotifyBasePath(path.dirname(getProjectConfigPath(cwd))) === buildExpectedNotifyBasePath(cwd)
548
- ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq.json`
549
- : getProjectConfigPath(cwd).split(path.sep).join("/");
572
+ function buildExpectedShowGlobalConfigPath(): string {
573
+ return buildExpectedDisplayPath(getGlobalConfigPath());
550
574
  }
551
575
 
552
576
  /**
@@ -815,6 +839,10 @@ test("extension registers prompt and config commands while exposing tool capabil
815
839
  "tokens",
816
840
  "files-static-check",
817
841
  "static-check",
842
+ "debug-compress",
843
+ "debug-references",
844
+ "debug-static-check",
845
+ "debug-tokens",
818
846
  "pi-usereq-show-config",
819
847
  "test-static-check",
820
848
  ]) {
@@ -839,6 +867,54 @@ test("extension registers prompt and config commands while exposing tool capabil
839
867
  }
840
868
  });
841
869
 
870
+ /**
871
+ * @brief Verifies debug tool wrapper commands register only when the local debug flag is enabled.
872
+ * @details Activates the extension once from a default-disabled fixture and once from a fixture whose local config enables debug tool wrapper commands, then asserts the four debug slash commands appear only in the enabled runtime. Runtime is dominated by fixture setup and extension registration. Side effects are limited to temporary filesystem mutation and process cwd changes.
873
+ * @return {void} No return value.
874
+ * @throws {AssertionError} Throws when command registration ignores the local debug tool wrapper flag.
875
+ * @satisfies TST-115
876
+ */
877
+ test("debug tool commands register only when enabled in local config", () => {
878
+ const disabledFixture = initFixtureRepo({ fixtures: [] });
879
+ const enabledFixture = initFixtureRepo({ fixtures: [] });
880
+ const debugCommandNames = [
881
+ "debug-compress",
882
+ "debug-references",
883
+ "debug-static-check",
884
+ "debug-tokens",
885
+ ];
886
+
887
+ try {
888
+ writeProjectConfigOverrides(enabledFixture.projectBase, {
889
+ DEBUG_TOOL_COMMANDS_ENABLED: "enable",
890
+ });
891
+
892
+ const previousCwd = process.cwd();
893
+ try {
894
+ process.chdir(disabledFixture.projectBase);
895
+ const disabledPi = createFakePi();
896
+ piUsereqExtension(disabledPi);
897
+ const disabledCommandNames = [...disabledPi.commands.keys()];
898
+ for (const commandName of debugCommandNames) {
899
+ assert.equal(disabledCommandNames.includes(commandName), false, `unexpected command ${commandName}`);
900
+ }
901
+
902
+ process.chdir(enabledFixture.projectBase);
903
+ const enabledPi = createFakePi();
904
+ piUsereqExtension(enabledPi);
905
+ const enabledCommandNames = [...enabledPi.commands.keys()];
906
+ for (const commandName of debugCommandNames) {
907
+ assert.ok(enabledCommandNames.includes(commandName), `missing command ${commandName}`);
908
+ }
909
+ } finally {
910
+ process.chdir(previousCwd);
911
+ }
912
+ } finally {
913
+ fs.rmSync(disabledFixture.projectBase, { recursive: true, force: true });
914
+ fs.rmSync(enabledFixture.projectBase, { recursive: true, force: true });
915
+ }
916
+ });
917
+
842
918
  /**
843
919
  * @brief Verifies `req-references` registration is independent from bundled prompt inventory.
844
920
  * @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.
@@ -1505,19 +1581,15 @@ test("static-check tools return monolithic text payloads with minimal execution
1505
1581
  assert.equal(staticCheckResult.details?.execution.code, 0);
1506
1582
  assert.deepEqual(staticCheckResult.details?.execution.stderr_lines, undefined);
1507
1583
 
1508
- fs.writeFileSync(
1509
- getProjectConfigPath(projectBase),
1510
- `${JSON.stringify({
1511
- ...getDefaultConfig(projectBase),
1512
- "docs-dir": DEFAULT_DOCS_DIR,
1513
- "tests-dir": "tests",
1514
- "src-dir": ["src"],
1515
- "static-check": {
1516
- TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1517
- },
1518
- }, null, 2)}\n`,
1519
- "utf8",
1520
- );
1584
+ saveFixtureConfigs(projectBase, {
1585
+ ...getDefaultConfig(projectBase),
1586
+ "docs-dir": DEFAULT_DOCS_DIR,
1587
+ "tests-dir": "tests",
1588
+ "src-dir": ["src"],
1589
+ "static-check": {
1590
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1591
+ },
1592
+ });
1521
1593
  const disabledFilesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1522
1594
  files: ["src/check.ts"],
1523
1595
  }) as {
@@ -1532,6 +1604,80 @@ test("static-check tools return monolithic text payloads with minimal execution
1532
1604
  }
1533
1605
  });
1534
1606
 
1607
+ /**
1608
+ * @brief Verifies debug tool wrapper commands mirror tool content and reject when disabled.
1609
+ * @details Enables debug tool wrapper commands for one fixture project, compares each debug slash-command editor payload against the matching tool `content[0].text`, then disables the flag and verifies the already-registered wrapper rejects further execution. Runtime is dominated by tool execution plus temporary git fixture setup. Side effects are limited to temporary filesystem mutation, editor-text capture, and process cwd changes.
1610
+ * @return {Promise<void>} Promise resolved when the verification completes.
1611
+ * @throws {AssertionError} Throws when a debug wrapper output diverges from the wrapped tool text or disabled execution is accepted.
1612
+ * @satisfies TST-116
1613
+ */
1614
+ test("debug tool wrapper commands mirror tool content and reject when disabled", async () => {
1615
+ const { projectBase } = initFixtureRepo({
1616
+ fixtures: [],
1617
+ staticCheck: {
1618
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "enable"),
1619
+ },
1620
+ });
1621
+ fs.writeFileSync(path.join(projectBase, "src", "check.ts"), "export const VALUE = 1;\n", "utf8");
1622
+ const add = spawnSync("git", ["add", "."], { cwd: projectBase, encoding: "utf8" });
1623
+ assert.equal(add.status, 0, add.stderr);
1624
+ const commit = spawnSync("git", ["commit", "-m", "add debug tool inputs"], { cwd: projectBase, encoding: "utf8" });
1625
+ assert.equal(commit.status, 0, commit.stderr);
1626
+
1627
+ const previousCwd = process.cwd();
1628
+ try {
1629
+ writeProjectConfigOverrides(projectBase, {
1630
+ DEBUG_TOOL_COMMANDS_ENABLED: "enable",
1631
+ });
1632
+
1633
+ process.chdir(projectBase);
1634
+ const pi = createFakePi();
1635
+ piUsereqExtension(pi);
1636
+
1637
+ const compressToolResult = await executeRegisteredTool(pi, "compress", projectBase, {}) as {
1638
+ content?: Array<{ type: string; text?: string }>;
1639
+ };
1640
+ const referencesToolResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1641
+ content?: Array<{ type: string; text?: string }>;
1642
+ };
1643
+ const staticCheckToolResult = await executeRegisteredTool(pi, "static-check", projectBase) as {
1644
+ content?: Array<{ type: string; text?: string }>;
1645
+ };
1646
+ const tokensToolResult = await executeRegisteredTool(pi, "tokens", projectBase, {}) as {
1647
+ content?: Array<{ type: string; text?: string }>;
1648
+ };
1649
+
1650
+ const compressCtx = createFakeCtx(projectBase);
1651
+ await pi.commands.get("debug-compress")!.handler("", compressCtx);
1652
+ assert.equal(compressCtx.__state.editorText, compressToolResult.content?.[0]?.text ?? "");
1653
+
1654
+ const referencesCtx = createFakeCtx(projectBase);
1655
+ await pi.commands.get("debug-references")!.handler("", referencesCtx);
1656
+ assert.equal(referencesCtx.__state.editorText, referencesToolResult.content?.[0]?.text ?? "");
1657
+
1658
+ const staticCheckCtx = createFakeCtx(projectBase);
1659
+ await pi.commands.get("debug-static-check")!.handler("", staticCheckCtx);
1660
+ assert.equal(staticCheckCtx.__state.editorText, staticCheckToolResult.content?.[0]?.text ?? "");
1661
+
1662
+ const tokensCtx = createFakeCtx(projectBase);
1663
+ await pi.commands.get("debug-tokens")!.handler("", tokensCtx);
1664
+ assert.equal(tokensCtx.__state.editorText, tokensToolResult.content?.[0]?.text ?? "");
1665
+
1666
+ writeProjectConfigOverrides(projectBase, {
1667
+ DEBUG_TOOL_COMMANDS_ENABLED: "disable",
1668
+ });
1669
+ const disabledCtx = createFakeCtx(projectBase);
1670
+ await assert.rejects(
1671
+ async () => pi.commands.get("debug-tokens")!.handler("", disabledCtx),
1672
+ /disabled by Debug > Enable debug commands for tools/u,
1673
+ );
1674
+ assert.equal(disabledCtx.__state.editorText, "");
1675
+ } finally {
1676
+ process.chdir(previousCwd);
1677
+ fs.rmSync(projectBase, { recursive: true, force: true });
1678
+ }
1679
+ });
1680
+
1535
1681
  test("configuration menu saves updated docs-dir in project config", async () => {
1536
1682
  const cwd = createTempDir("pi-usereq-menu-");
1537
1683
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1541,7 +1687,7 @@ test("configuration menu saves updated docs-dir in project config", async () =>
1541
1687
  assert.ok(command);
1542
1688
 
1543
1689
  await command!.handler("", createFakeCtx(cwd, { selects: ["docs-dir", "Save and close"], inputs: ["docs/custom"] }));
1544
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1690
+ const config = readProjectConfigJson(cwd);
1545
1691
  assert.equal(config["docs-dir"], "docs/custom");
1546
1692
  });
1547
1693
 
@@ -1566,7 +1712,7 @@ test("configuration menu persists trailing-slash-free relative directory values"
1566
1712
 
1567
1713
  await command!.handler("", ctx);
1568
1714
 
1569
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1715
+ const config = readProjectConfigJson(cwd);
1570
1716
  assert.equal(config["docs-dir"], "docs/custom");
1571
1717
  assert.equal(config["tests-dir"], "tests/custom");
1572
1718
  assert.deepEqual(config["src-dir"], ["src", "src/custom"]);
@@ -1586,12 +1732,14 @@ test("configuration menu forces worktree rows off and dim when auto git commit i
1586
1732
 
1587
1733
  await command!.handler("", ctx);
1588
1734
 
1589
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1735
+ const localConfig = readProjectConfigJson(cwd);
1736
+ const globalConfig = readGlobalConfigJson();
1590
1737
  const defaultConfig = getDefaultConfig(cwd);
1591
1738
  const renderedLockedMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1592
- assert.equal(config.AUTO_GIT_COMMIT, "disable");
1593
- assert.equal(config.GIT_WORKTREE_ENABLED, "disable");
1594
- assert.equal(config.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1739
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "disable");
1740
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "disable");
1741
+ assert.equal(globalConfig.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1742
+ assert.equal(Object.hasOwn(localConfig, "AUTO_GIT_COMMIT"), false);
1595
1743
  assert.match(renderedLockedMenu, /<dim>Git worktree/);
1596
1744
  assert.match(renderedLockedMenu, /<dim>Worktree prefix/);
1597
1745
  });
@@ -1612,7 +1760,7 @@ test("configuration menu omits prompt-delivery controls and persisted config omi
1612
1760
  assert.equal(fs.existsSync(getProjectConfigPath(cwd)), false);
1613
1761
  });
1614
1762
 
1615
- test("configuration menus expose show-config ordering and omit overview or notification-reference rows", async () => {
1763
+ test("configuration menus expose local/global config ordering and omit overview or notification-reference rows", async () => {
1616
1764
  const cwd = fs.mkdtempSync(
1617
1765
  path.join(process.env.HOME ?? process.cwd(), "pi-usereq-menu-structure-"),
1618
1766
  );
@@ -1638,10 +1786,18 @@ test("configuration menus expose show-config ordering and omit overview or notif
1638
1786
  "Enable tools",
1639
1787
  "Notifications",
1640
1788
  "Debug",
1641
- "Show configuration",
1789
+ "Show local configuration",
1790
+ "Show global configuration",
1642
1791
  "Reset defaults",
1643
1792
  ]);
1644
- assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1793
+ assert.match(
1794
+ renderedMenu,
1795
+ new RegExp(`<dim>${buildExpectedShowLocalConfigPath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1796
+ );
1797
+ assert.match(
1798
+ renderedMenu,
1799
+ new RegExp(`<dim>${buildExpectedShowGlobalConfigPath().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dim>`),
1800
+ );
1645
1801
  assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1646
1802
  assert.ok(renderedMenu.includes("disable"));
1647
1803
  assert.doesNotMatch(renderedMenu, /beep:/);
@@ -1668,15 +1824,15 @@ test("configuration menus expose show-config ordering and omit overview or notif
1668
1824
  ]);
1669
1825
  });
1670
1826
 
1671
- test("show configuration saves pending config, closes the menu, and writes the persisted project config file text into the editor", async () => {
1672
- const cwd = createTempDir("pi-usereq-menu-show-config-");
1827
+ test("show local configuration saves pending config, closes the menu, and writes the persisted local config file text into the editor", async () => {
1828
+ const cwd = createTempDir("pi-usereq-menu-show-local-config-");
1673
1829
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1674
1830
  const pi = createFakePi();
1675
1831
  piUsereqExtension(pi);
1676
1832
  const command = pi.commands.get("pi-usereq");
1677
1833
  assert.ok(command);
1678
1834
  const ctx = createFakeCtx(cwd, {
1679
- selects: ["docs-dir", "show-config"],
1835
+ selects: ["docs-dir", "show-local-config"],
1680
1836
  inputs: ["docs/custom"],
1681
1837
  });
1682
1838
 
@@ -1691,6 +1847,28 @@ test("show configuration saves pending config, closes the menu, and writes the p
1691
1847
  );
1692
1848
  });
1693
1849
 
1850
+ test("show global configuration saves pending config, closes the menu, and writes the persisted global config file text into the editor", async () => {
1851
+ const cwd = createTempDir("pi-usereq-menu-show-global-config-");
1852
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1853
+ const pi = createFakePi();
1854
+ piUsereqExtension(pi);
1855
+ const command = pi.commands.get("pi-usereq");
1856
+ assert.ok(command);
1857
+ const ctx = createFakeCtx(cwd, {
1858
+ selects: ["Auto git commit", "show-global-config"],
1859
+ });
1860
+
1861
+ await command!.handler("", ctx);
1862
+
1863
+ const persistedText = fs.readFileSync(getGlobalConfigPath(), "utf8");
1864
+ assert.equal(ctx.__state.editorText, persistedText);
1865
+ assert.match(ctx.__state.editorText, /"AUTO_GIT_COMMIT": "disable"/);
1866
+ assert.equal(
1867
+ ctx.__state.selectCalls.filter((call) => call.title === "pi-usereq").length,
1868
+ 2,
1869
+ );
1870
+ });
1871
+
1694
1872
  test("descendant configuration menus end with Reset defaults only", async () => {
1695
1873
  const cwd = createTempDir("pi-usereq-menu-terminal-rows-");
1696
1874
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1770,6 +1948,7 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1770
1948
  "Debug",
1771
1949
  "Debug",
1772
1950
  "Log file",
1951
+ "Enable debug commands for tools",
1773
1952
  "Log on status",
1774
1953
  "any",
1775
1954
  "Status changes",
@@ -1785,35 +1964,43 @@ test("debug menu dims locked rows and persists debug settings with focus-preserv
1785
1964
  await command!.handler("", ctx);
1786
1965
 
1787
1966
  const renderedInitialDebugMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1788
- const persistedConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1967
+ const persistedConfig = readProjectConfigJson(cwd);
1789
1968
  const debugCalls = ctx.__state.selectCalls.filter((call) => call.title === "Debug");
1790
1969
  assert.match(renderedInitialDebugMenu, /<dim>Log file/);
1970
+ assert.match(renderedInitialDebugMenu, /<dim>Enable debug commands for tools/);
1791
1971
  assert.match(renderedInitialDebugMenu, /<dim>Log on status/);
1792
1972
  assert.match(renderedInitialDebugMenu, /<dim>Status changes/);
1793
1973
  assert.match(renderedInitialDebugMenu, /<dim>Workflow events/);
1794
1974
  assert.match(renderedInitialDebugMenu, /<dim>compress/);
1795
1975
  assert.equal(persistedConfig.DEBUG_ENABLED, "enable");
1796
1976
  assert.equal(persistedConfig.DEBUG_LOG_FILE, "logs/debug-output.json");
1977
+ assert.equal(persistedConfig.DEBUG_TOOL_COMMANDS_ENABLED, "enable");
1797
1978
  assert.equal(persistedConfig.DEBUG_STATUS_CHANGES, "enable");
1798
1979
  assert.equal(persistedConfig.DEBUG_WORKFLOW_EVENTS, "enable");
1799
1980
  assert.equal(persistedConfig.DEBUG_LOG_ON_STATUS, "any");
1981
+ assert.ok(pi.commands.get("debug-compress"));
1982
+ assert.ok(pi.commands.get("debug-references"));
1983
+ assert.ok(pi.commands.get("debug-static-check"));
1984
+ assert.ok(pi.commands.get("debug-tokens"));
1800
1985
  assert.deepEqual(persistedConfig.DEBUG_ENABLED_TOOLS, ["files-tokens"]);
1801
1986
  assert.deepEqual(persistedConfig.DEBUG_ENABLED_PROMPTS, ["req-analyze"]);
1802
1987
  assert.equal(debugCalls[1]?.selectedChoiceId, "debug-enabled");
1803
1988
  assert.equal(debugCalls[2]?.selectedChoiceId, "debug-log-file");
1804
- assert.equal(debugCalls[3]?.selectedChoiceId, "debug-log-on-status");
1805
- assert.equal(debugCalls[4]?.selectedChoiceId, "debug-status-changes");
1806
- assert.equal(debugCalls[5]?.selectedChoiceId, "debug-workflow-events");
1807
- assert.equal(debugCalls[6]?.selectedChoiceId, "debug-tool:files-tokens");
1808
- assert.equal(debugCalls[7]?.selectedChoiceId, "debug-prompt:req-analyze");
1989
+ assert.equal(debugCalls[3]?.selectedChoiceId, "debug-tool-commands-enabled");
1990
+ assert.equal(debugCalls[4]?.selectedChoiceId, "debug-log-on-status");
1991
+ assert.equal(debugCalls[5]?.selectedChoiceId, "debug-status-changes");
1992
+ assert.equal(debugCalls[6]?.selectedChoiceId, "debug-workflow-events");
1993
+ assert.equal(debugCalls[7]?.selectedChoiceId, "debug-tool:files-tokens");
1994
+ assert.equal(debugCalls[8]?.selectedChoiceId, "debug-prompt:req-analyze");
1809
1995
 
1810
1996
  const resetCtx = createFakeCtx(cwd, {
1811
1997
  selects: ["Debug", "Reset defaults", "Approve reset", "Save and close"],
1812
1998
  });
1813
1999
  await command!.handler("", resetCtx);
1814
- const resetConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2000
+ const resetConfig = readProjectConfigJson(cwd);
1815
2001
  assert.equal(resetConfig.DEBUG_ENABLED, "disable");
1816
2002
  assert.equal(resetConfig.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
2003
+ assert.equal(resetConfig.DEBUG_TOOL_COMMANDS_ENABLED, DEFAULT_DEBUG_TOOL_COMMANDS_ENABLED);
1817
2004
  assert.equal(resetConfig.DEBUG_STATUS_CHANGES, "disable");
1818
2005
  assert.equal(resetConfig.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
1819
2006
  assert.equal(resetConfig.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
@@ -1837,6 +2024,7 @@ test("debug menu rows derive from canonical tool and prompt inventories", async
1837
2024
  assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1838
2025
  "Debug",
1839
2026
  "Log file",
2027
+ "Enable debug commands for tools",
1840
2028
  "Log on status",
1841
2029
  "Status changes",
1842
2030
  "Workflow events",
@@ -1856,17 +2044,13 @@ test("debug menu rows derive from canonical tool and prompt inventories", async
1856
2044
  test("tool debug logging honors global enablement and workflow-status filters", async () => {
1857
2045
  const idleBase = createTempDir("pi-usereq-tool-debug-idle-");
1858
2046
  fs.mkdirSync(path.dirname(getProjectConfigPath(idleBase)), { recursive: true });
1859
- fs.writeFileSync(
1860
- getProjectConfigPath(idleBase),
1861
- `${JSON.stringify({
1862
- ...getDefaultConfig(idleBase),
1863
- DEBUG_ENABLED: "enable",
1864
- DEBUG_LOG_FILE: "tool-debug.json",
1865
- DEBUG_LOG_ON_STATUS: "any",
1866
- DEBUG_ENABLED_TOOLS: ["bash"],
1867
- }, null, 2)}\n`,
1868
- "utf8",
1869
- );
2047
+ saveFixtureConfigs(idleBase, {
2048
+ ...getDefaultConfig(idleBase),
2049
+ DEBUG_ENABLED: "enable",
2050
+ DEBUG_LOG_FILE: "tool-debug.json",
2051
+ DEBUG_LOG_ON_STATUS: "any",
2052
+ DEBUG_ENABLED_TOOLS: ["bash"],
2053
+ });
1870
2054
  const idlePi = createFakePi();
1871
2055
  piUsereqExtension(idlePi);
1872
2056
  const idleCtx = createFakeCtx(idleBase);
@@ -1927,17 +2111,13 @@ test("tool debug logging honors global enablement and workflow-status filters",
1927
2111
 
1928
2112
  const disabledBase = createTempDir("pi-usereq-tool-debug-disabled-");
1929
2113
  fs.mkdirSync(path.dirname(getProjectConfigPath(disabledBase)), { recursive: true });
1930
- fs.writeFileSync(
1931
- getProjectConfigPath(disabledBase),
1932
- `${JSON.stringify({
1933
- ...getDefaultConfig(disabledBase),
1934
- DEBUG_ENABLED: "disable",
1935
- DEBUG_LOG_FILE: "tool-debug.json",
1936
- DEBUG_LOG_ON_STATUS: "any",
1937
- DEBUG_ENABLED_TOOLS: ["bash"],
1938
- }, null, 2)}\n`,
1939
- "utf8",
1940
- );
2114
+ saveFixtureConfigs(disabledBase, {
2115
+ ...getDefaultConfig(disabledBase),
2116
+ DEBUG_ENABLED: "disable",
2117
+ DEBUG_LOG_FILE: "tool-debug.json",
2118
+ DEBUG_LOG_ON_STATUS: "any",
2119
+ DEBUG_ENABLED_TOOLS: ["bash"],
2120
+ });
1941
2121
  const disabledPi = createFakePi();
1942
2122
  piUsereqExtension(disabledPi);
1943
2123
  const disabledCtx = createFakeCtx(disabledBase);
@@ -2181,11 +2361,12 @@ test("notifications reset defaults preserves non-notification settings", async (
2181
2361
 
2182
2362
  await command!.handler("", ctx);
2183
2363
 
2184
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2185
- assert.equal(config["docs-dir"], "docs/custom");
2186
- assert.equal(config["notify-enabled"], false);
2187
- assert.equal(config["notify-sound"], "none");
2188
- assert.equal(config["notify-pushover-enabled"], false);
2364
+ const localConfig = readProjectConfigJson(cwd);
2365
+ const globalConfig = readGlobalConfigJson();
2366
+ assert.equal(localConfig["docs-dir"], "docs/custom");
2367
+ assert.equal(globalConfig["notify-enabled"], false);
2368
+ assert.equal(globalConfig["notify-sound"], "none");
2369
+ assert.equal(globalConfig["notify-pushover-enabled"], false);
2189
2370
  });
2190
2371
 
2191
2372
  test("top-level reset defaults restores all configuration values", async () => {
@@ -2210,11 +2391,12 @@ test("top-level reset defaults restores all configuration values", async () => {
2210
2391
 
2211
2392
  await command!.handler("", ctx);
2212
2393
 
2213
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2214
- assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
2215
- assert.deepEqual(config["src-dir"], ["src"]);
2216
- assert.equal(config["notify-enabled"], false);
2217
- assert.equal(config["notify-sound"], "none");
2394
+ const localConfig = readProjectConfigJson(cwd);
2395
+ const globalConfig = readGlobalConfigJson();
2396
+ assert.equal(localConfig["docs-dir"], DEFAULT_DOCS_DIR);
2397
+ assert.deepEqual(localConfig["src-dir"], ["src"]);
2398
+ assert.equal(globalConfig["notify-enabled"], false);
2399
+ assert.equal(globalConfig["notify-sound"], "none");
2218
2400
  });
2219
2401
 
2220
2402
  test("configuration menus reuse CLI settings theme semantics", async () => {
@@ -4795,17 +4977,11 @@ test("prompt commands remain functional when custom tool registrations are remov
4795
4977
  test("session_start applies configured pi-usereq startup tools", async () => {
4796
4978
  const cwd = createTempDir("pi-usereq-tools-startup-");
4797
4979
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4798
- fs.writeFileSync(
4799
- getProjectConfigPath(cwd),
4800
- `${JSON.stringify({
4801
- "docs-dir": DEFAULT_DOCS_DIR,
4802
- "tests-dir": "tests",
4803
- "src-dir": ["src", "foobar"],
4804
- "static-check": {},
4805
- "enabled-tools": ["files-tokens", "static-check"],
4806
- }, null, 2)}\n`,
4807
- "utf8",
4808
- );
4980
+ saveFixtureConfigs(cwd, {
4981
+ ...getDefaultConfig(cwd),
4982
+ "src-dir": ["src", "foobar"],
4983
+ "enabled-tools": ["files-tokens", "static-check"],
4984
+ });
4809
4985
 
4810
4986
  const pi = createFakePi();
4811
4987
  piUsereqExtension(pi);
@@ -4915,7 +5091,7 @@ test("stale replacement contexts do not break workflow-state rendering", () => {
4915
5091
  const cwd = createTempDir("pi-usereq-stale-render-");
4916
5092
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4917
5093
  const controller = createPiUsereqStatusController();
4918
- setPiUsereqStatusConfig(controller, getDefaultConfig());
5094
+ setPiUsereqStatusConfig(controller, getDefaultConfig(cwd));
4919
5095
  const staleCtx = createFakeCtx(cwd);
4920
5096
  Object.defineProperty(staleCtx, "ui", {
4921
5097
  configurable: true,
@@ -5296,7 +5472,9 @@ test("session_start enables default custom tools and default embedded tools only
5296
5472
  });
5297
5473
 
5298
5474
  test("default configuration applies the documented static-check, debug, notify, sound, and pushover defaults", () => {
5299
- const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
5475
+ const cwd = createTempDir("pi-usereq-default-notify-");
5476
+ const config = getDefaultConfig(cwd);
5477
+ saveFixtureConfigs(cwd, config);
5300
5478
 
5301
5479
  assert.equal(config.AUTO_GIT_COMMIT, "enable");
5302
5480
  assert.deepEqual(Object.keys(config["static-check"]).length, 20);
@@ -5345,6 +5523,7 @@ test("default configuration applies the documented static-check, debug, notify,
5345
5523
  assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([], "disable"));
5346
5524
  assert.equal(config.DEBUG_ENABLED, "disable");
5347
5525
  assert.equal(config.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
5526
+ assert.equal(config.DEBUG_TOOL_COMMANDS_ENABLED, DEFAULT_DEBUG_TOOL_COMMANDS_ENABLED);
5348
5527
  assert.equal(config.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
5349
5528
  assert.equal(config.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
5350
5529
  assert.deepEqual(config.DEBUG_ENABLED_TOOLS, []);
@@ -5363,6 +5542,22 @@ test("default configuration applies the documented static-check, debug, notify,
5363
5542
  assert.equal(config["notify-pushover-on-failed"], false);
5364
5543
  assert.equal(config.PI_NOTIFY_CMD, 'notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a "PI-useReq" "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"');
5365
5544
  assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
5545
+
5546
+ const localConfig = readProjectConfigJson(cwd);
5547
+ const globalConfig = readGlobalConfigJson();
5548
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "enable");
5549
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "disable");
5550
+ assert.equal(localConfig.DEBUG_TOOL_COMMANDS_ENABLED, DEFAULT_DEBUG_TOOL_COMMANDS_ENABLED);
5551
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
5552
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5553
+ { module: "Command", cmd: "ruff", params: ["check"] },
5554
+ ]);
5555
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, []);
5556
+ assert.equal(globalConfig.AUTO_GIT_COMMIT, "enable");
5557
+ assert.equal(globalConfig.GIT_WORKTREE_ENABLED, "enable");
5558
+ assert.equal(globalConfig["notify-enabled"], false);
5559
+ assert.equal(Object.hasOwn(localConfig, "notify-enabled"), false);
5560
+ assert.equal(Object.hasOwn(localConfig, "enabled-tools"), false);
5366
5561
  });
5367
5562
 
5368
5563
  test("configuration menu orders tool toggles and can enable embedded builtin tools", async () => {
@@ -5378,7 +5573,7 @@ test("configuration menu orders tool toggles and can enable embedded builtin too
5378
5573
 
5379
5574
  await command!.handler("", ctx);
5380
5575
 
5381
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5576
+ const config = readGlobalConfigJson();
5382
5577
  assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
5383
5578
  "compress",
5384
5579
  "references",
@@ -5419,7 +5614,7 @@ test("configuration menu can disable configurable active tools", async () => {
5419
5614
  }),
5420
5615
  );
5421
5616
 
5422
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5617
+ const config = readGlobalConfigJson();
5423
5618
  assert.deepEqual(config["enabled-tools"], []);
5424
5619
  assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
5425
5620
  });
@@ -5463,7 +5658,7 @@ test("configuration menu can persist notify and boot-sound settings without chan
5463
5658
  await pi.emit("session_start", { reason: "startup" }, ctx);
5464
5659
  await command!.handler("", ctx);
5465
5660
 
5466
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5661
+ const config = readGlobalConfigJson();
5467
5662
  assert.equal(config["notify-enabled"], true);
5468
5663
  assert.equal(config["notify-on-completed"], true);
5469
5664
  assert.equal(config["notify-on-interrupted"], false);
@@ -5524,7 +5719,7 @@ test("configuration menu can persist pushover settings", async () => {
5524
5719
 
5525
5720
  await command!.handler("", ctx);
5526
5721
 
5527
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5722
+ const config = readGlobalConfigJson();
5528
5723
  assert.equal(config["notify-pushover-enabled"], true);
5529
5724
  assert.equal(config["notify-pushover-on-completed"], true);
5530
5725
  assert.equal(config["notify-pushover-on-interrupted"], false);
@@ -5551,14 +5746,10 @@ test("configuration menu can persist pushover settings", async () => {
5551
5746
  test("notifications menu escapes pushover text and locks pushover enablement until credentials exist", async () => {
5552
5747
  const cwd = createTempDir("pi-usereq-menu-pushover-display-");
5553
5748
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
5554
- fs.writeFileSync(
5555
- getProjectConfigPath(cwd),
5556
- `${JSON.stringify({
5557
- ...getDefaultConfig(cwd),
5558
- "notify-pushover-text": "first line\nsecond line",
5559
- }, null, 2)}\n`,
5560
- "utf8",
5561
- );
5749
+ saveFixtureConfigs(cwd, {
5750
+ ...getDefaultConfig(cwd),
5751
+ "notify-pushover-text": "first line\nsecond line",
5752
+ });
5562
5753
  const pi = createFakePi();
5563
5754
  piUsereqExtension(pi);
5564
5755
  const command = pi.commands.get("pi-usereq");
@@ -5784,7 +5975,7 @@ test("pushover global enable controls status and suppresses delivery when disabl
5784
5975
  await command!.handler("", ctx);
5785
5976
  writeProjectConfigOverrides(cwd, { GIT_WORKTREE_ENABLED: "disable" });
5786
5977
 
5787
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5978
+ const config = readGlobalConfigJson();
5788
5979
  assert.equal(config["notify-pushover-enabled"], false);
5789
5980
  assert.equal(config["notify-pushover-on-completed"], true);
5790
5981
  assert.equal(
@@ -6038,8 +6229,9 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
6038
6229
  test("sound toggle shortcut cycles active runtime pi-notify sound levels without persisting boot config", async () => {
6039
6230
  const cwd = createTempDir("pi-usereq-shortcut-notify-");
6040
6231
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6041
- const persistedConfigText = `${JSON.stringify(getDefaultConfig(cwd), null, 2)}\n`;
6042
- fs.writeFileSync(getProjectConfigPath(cwd), persistedConfigText, "utf8");
6232
+ const persistedConfig = getDefaultConfig(cwd);
6233
+ saveFixtureConfigs(cwd, persistedConfig);
6234
+ const persistedGlobalConfigText = fs.readFileSync(getGlobalConfigPath(), "utf8");
6043
6235
  const previousCwd = process.cwd();
6044
6236
  const pi = createFakePi();
6045
6237
  process.chdir(cwd);
@@ -6056,7 +6248,7 @@ test("sound toggle shortcut cycles active runtime pi-notify sound levels without
6056
6248
 
6057
6249
  for (const expectedSound of ["low", "mid", "high", "none"]) {
6058
6250
  await shortcut!.handler(ctx);
6059
- assert.equal(fs.readFileSync(getProjectConfigPath(cwd), "utf8"), persistedConfigText);
6251
+ assert.equal(fs.readFileSync(getGlobalConfigPath(), "utf8"), persistedGlobalConfigText);
6060
6252
  assert.equal(
6061
6253
  ctx.__state.statuses.get("pi-usereq"),
6062
6254
  buildExpectedFakeStatusText({
@@ -6077,11 +6269,7 @@ test("session_start loads the active runtime sound from persisted boot config",
6077
6269
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
6078
6270
  const persistedConfig = getDefaultConfig(cwd);
6079
6271
  persistedConfig["notify-sound"] = "mid";
6080
- fs.writeFileSync(
6081
- getProjectConfigPath(cwd),
6082
- `${JSON.stringify(persistedConfig, null, 2)}\n`,
6083
- "utf8",
6084
- );
6272
+ saveFixtureConfigs(cwd, persistedConfig);
6085
6273
  const previousCwd = process.cwd();
6086
6274
  const pi = createFakePi();
6087
6275
  process.chdir(cwd);
@@ -6180,9 +6368,10 @@ test("configuration menu can toggle per-language static-check enablement", async
6180
6368
  }),
6181
6369
  );
6182
6370
 
6183
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
6184
- assert.equal(config["static-check"].Python.enabled, "disable");
6185
- assert.deepEqual(config["static-check"].Python.checkers, [
6371
+ const localConfig = readProjectConfigJson(cwd);
6372
+ const globalConfig = readGlobalConfigJson();
6373
+ assert.equal((localConfig["static-check"] as Record<string, any>).Python.enabled, "disable");
6374
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Python.checkers, [
6186
6375
  { module: "Command", cmd: "pyright", params: ["--outputjson"] },
6187
6376
  { module: "Command", cmd: "ruff", params: ["check"] },
6188
6377
  ]);
@@ -6210,8 +6399,10 @@ test("configuration menu can add guided static-check entries for explicit suppor
6210
6399
  }),
6211
6400
  );
6212
6401
 
6213
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
6214
- assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([
6402
+ const localConfig = readProjectConfigJson(cwd);
6403
+ const globalConfig = readGlobalConfigJson();
6404
+ assert.equal((localConfig["static-check"] as Record<string, any>).Ruby.enabled, "enable");
6405
+ assert.deepEqual((globalConfig["static-check"] as Record<string, any>).Ruby.checkers, [
6215
6406
  { module: "Command", cmd: "true" },
6216
- ], "enable"));
6407
+ ]);
6217
6408
  });