pi-usereq 0.5.0 → 0.7.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.
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import piUsereqExtension from "../src/index.ts";
7
8
  import {
@@ -9,8 +10,13 @@ import {
9
10
  getDefaultConfig,
10
11
  getProjectConfigPath,
11
12
  } from "../src/core/config.js";
12
- import { formatRuntimePathForDisplay } from "../src/core/path-context.js";
13
- import { setPiNotifyHttpsRequestForTests } from "../src/core/pi-notify.js";
13
+ import {
14
+ setJsTiktokenModuleLoaderForTests,
15
+ } from "../src/core/token-counter.js";
16
+ import {
17
+ setPiNotifyHttpsRequestForTests,
18
+ setPiNotifySpawnForTests,
19
+ } from "../src/core/pi-notify.js";
14
20
  import {
15
21
  PI_USEREQ_DEFAULT_ENABLED_TOOL_NAMES,
16
22
  PI_USEREQ_EMBEDDED_TOOL_NAMES,
@@ -289,42 +295,43 @@ function formatFakeThemeBackgroundFromForeground(color: string, text: string): s
289
295
  }
290
296
 
291
297
  /**
292
- * @brief Builds the expected fake context-bar payload for assertions.
293
- * @details Resolves the empty-state `◀ CLEAR ▶ ` overlay for unavailable or
294
- * non-positive percent, resolves the centered ` ◀ FULL ▶ ` overlay for percent
295
- * above 90, and otherwise reconstructs the 10-cell bar. Runtime is O(1). No
296
- * external state is mutated.
297
- * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-bar facts.
298
- * @return {string} Encoded context-bar string.
298
+ * @brief Builds the expected fake context-gauge payload for assertions.
299
+ * @details Resolves the documented icon thresholds for `0-100%` and emits the
300
+ * blinking red overflow icon for percent values above `100`. Runtime is O(1).
301
+ * No external state is mutated.
302
+ * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
303
+ * @return {string} Encoded context-gauge string.
299
304
  */
300
305
  function buildExpectedFakeContextBar(options: {
301
306
  filledCells: number;
302
307
  percent?: number | null;
303
308
  }): string {
304
- if (options.percent === undefined || options.percent === null || options.percent <= 0) {
305
- return formatFakeThemeBackgroundFromForeground(
306
- "accent",
307
- formatFakeThemeForeground("warning", "◀ CLEAR ▶ "),
308
- );
309
+ const percent = options.percent;
310
+ if (percent === undefined || percent === null || percent <= 0) {
311
+ return formatFakeThemeForeground("warning", "▕_▏");
309
312
  }
310
- if (options.percent > 90) {
311
- return formatFakeThemeBackgroundFromForeground(
312
- "warning",
313
- formatFakeThemeForeground("error", " ◀ FULL ▶ "),
314
- );
313
+ if (percent > 100) {
314
+ return formatFakeThemeForeground("error", "\u001b[5m▕█▏\u001b[25m");
315
+ }
316
+ if (percent <= 25) {
317
+ return formatFakeThemeForeground("warning", "▕▂▏");
315
318
  }
316
- return Array.from({ length: 10 }, (_value, index) =>
317
- formatFakeThemeBackgroundFromForeground(
318
- "accent",
319
- formatFakeThemeForeground(index < options.filledCells ? "warning" : "dim", "▓"),
320
- ),
321
- ).join("");
319
+ if (percent <= 50) {
320
+ return formatFakeThemeForeground("warning", "▕▄▏");
321
+ }
322
+ if (percent <= 90) {
323
+ return formatFakeThemeForeground("warning", "▕▆▏");
324
+ }
325
+ return formatFakeThemeForeground("warning", "▕█▏");
322
326
  }
323
327
 
324
328
  /**
325
329
  * @brief Builds the expected fake pi-usereq status-bar string for assertions.
326
- * @details Reconstructs the field order, separators, context bar, consolidated elapsed field, and Pushover enable field emitted by the extension using deterministic fake theme markers. Runtime is O(1). No external state is mutated.
327
- * @param[in] options {{ basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; beep?: string; sound?: string; pushover?: string }} Expected status facts.
330
+ * @details Reconstructs the field order, separators, icon-based context gauge,
331
+ * consolidated elapsed field, and sound field emitted by the extension using
332
+ * deterministic fake theme markers. Runtime is O(1). No external state is
333
+ * mutated.
334
+ * @param[in] options {{ basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts.
328
335
  * @return {string} Encoded status-bar string.
329
336
  */
330
337
  function buildExpectedFakeStatusText(options: {
@@ -335,9 +342,7 @@ function buildExpectedFakeStatusText(options: {
335
342
  contextFilledCells: number;
336
343
  contextPercent?: number | null;
337
344
  et: string;
338
- beep?: string;
339
345
  sound?: string;
340
- pushover?: string;
341
346
  }): string {
342
347
  const buildField = (fieldName: string, value: string): string =>
343
348
  `${formatFakeThemeForeground("accent", `${fieldName}:`)}${formatFakeThemeForeground("warning", value)}`;
@@ -349,9 +354,7 @@ function buildExpectedFakeStatusText(options: {
349
354
  buildField("base", options.basePath),
350
355
  `${formatFakeThemeForeground("accent", "context:")}${contextBar}`,
351
356
  buildField("elapsed", options.et),
352
- buildField("beep", options.beep ?? "end,esc,err"),
353
357
  buildField("sound", options.sound ?? "none"),
354
- buildField("pushover", options.pushover ?? "off"),
355
358
  ].join(formatFakeThemeForeground("dim", " • "));
356
359
  }
357
360
 
@@ -367,6 +370,54 @@ function buildExpectedFakeBasePath(cwd: string): string {
367
370
  return path.resolve(cwd).split(path.sep).join("/");
368
371
  }
369
372
 
373
+ /**
374
+ * @brief Formats one expected home-relative base path for notify-template assertions.
375
+ * @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.
376
+ * @param[in] cwd {string} Runtime working directory.
377
+ * @return {string} Home-relative or absolute base path used by notification templates.
378
+ */
379
+ function buildExpectedNotifyBasePath(cwd: string): string {
380
+ const resolvedPath = path.resolve(cwd);
381
+ const homePath = path.resolve(process.env.HOME ?? os.homedir());
382
+ if (resolvedPath === homePath) {
383
+ return "~";
384
+ }
385
+ const relativePath = path.relative(homePath, resolvedPath);
386
+ if (relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
387
+ return `~/${relativePath.split(path.sep).join("/")}`;
388
+ }
389
+ return resolvedPath.split(path.sep).join("/");
390
+ }
391
+
392
+ /**
393
+ * @brief Formats the expected `Show configuration` path value.
394
+ * @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.
395
+ * @param[in] cwd {string} Runtime working directory.
396
+ * @return {string} `~`-relative or absolute config-path display string.
397
+ */
398
+ function buildExpectedShowConfigPath(cwd: string): string {
399
+ return buildExpectedNotifyBasePath(path.dirname(path.dirname(getProjectConfigPath(cwd)))) === buildExpectedNotifyBasePath(cwd)
400
+ ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq/config.json`
401
+ : getProjectConfigPath(cwd).split(path.sep).join("/");
402
+ }
403
+
404
+ /**
405
+ * @brief Formats the expected `%%RESULT%%` placeholder value for one terminal stop reason.
406
+ * @details Maps the tested assistant stop reasons to the literal runtime result tokens required by the default notify and Pushover templates. Runtime is O(1). No external state is mutated.
407
+ * @param[in] stopReason {"stop" | "aborted" | "error"} Terminal stop reason under test.
408
+ * @return {string} `successed`, `aborted`, or `failed`.
409
+ */
410
+ function buildExpectedNotifyResult(stopReason: "stop" | "aborted" | "error"): string {
411
+ switch (stopReason) {
412
+ case "aborted":
413
+ return "aborted";
414
+ case "error":
415
+ return "failed";
416
+ default:
417
+ return "successed";
418
+ }
419
+ }
420
+
370
421
  /**
371
422
  * @brief Creates a fake extension command context with scripted UI interactions.
372
423
  * @details Materializes deterministic `select`, shared settings-menu `custom`, `input`, `notify`, `setStatus`, `setEditorText`, `waitForIdle`, `newSession`, `sessionManager`, `getContextUsage`, theme-color behaviors, and captured custom-menu render output backed by in-memory state so menu-oriented and prompt-command tests can assert side effects without a real UI. Optional `onNewSession` and theme overrides let tests emulate runtime replacement and strict token validation. Runtime is O(1) plus queued interaction count and delegated new-session setup cost. Side effects are limited to in-memory state mutation.
@@ -389,7 +440,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
389
440
  editorText: "",
390
441
  statuses: new Map<string, string>(),
391
442
  notifications: [] as Array<{ message: string; level: string }>,
392
- selectCalls: [] as Array<{ title: string; items: string[] }>,
443
+ selectCalls: [] as Array<{ title: string; items: string[]; selectedChoiceId?: string }>,
393
444
  customRenderLines: [] as string[][],
394
445
  waitForIdleCalls: 0,
395
446
  newSessions: [] as Array<{ messages: string[] }>,
@@ -460,6 +511,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
460
511
  __piUsereqSettingsMenu?: {
461
512
  title: string;
462
513
  choices: Array<{ label: string }>;
514
+ selectedChoiceId?: string;
463
515
  selectByLabel: (label: string) => boolean;
464
516
  cancel: () => void;
465
517
  };
@@ -470,7 +522,11 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
470
522
  state.customRenderLines.push(component.render(120));
471
523
  }
472
524
  const response = selects.shift();
473
- state.selectCalls.push({ title: bridge.title, items: bridge.choices.map((choice) => choice.label) });
525
+ state.selectCalls.push({
526
+ title: bridge.title,
527
+ items: bridge.choices.map((choice) => choice.label),
528
+ selectedChoiceId: bridge.selectedChoiceId,
529
+ });
474
530
  if (response === undefined) {
475
531
  bridge.cancel();
476
532
  } else {
@@ -606,13 +662,13 @@ test("token tools register agent-oriented descriptions and schema details", () =
606
662
  assert.ok(filesTokens, "missing files-tokens tool");
607
663
  assert.ok(tokens, "missing tokens tool");
608
664
 
609
- assert.match(filesTokens.description ?? "", /LLM-oriented JSON payload/);
665
+ assert.match(filesTokens.description ?? "", /token-optimized JSON payload/);
610
666
  assert.ok(filesTokens.promptGuidelines?.some((line) => line.includes("Output contract:")));
611
- assert.match(String((filesTokens.parameters as { description?: string } | undefined)?.description ?? ""), /line ranges/);
667
+ assert.match(String((filesTokens.parameters as { description?: string } | undefined)?.description ?? ""), /token metrics/);
612
668
 
613
669
  assert.match(tokens.description ?? "", /canonical docs/);
614
- assert.ok(tokens.promptGuidelines?.some((line) => line.includes("docs_dir_path")));
615
- assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /canonical_doc_names/);
670
+ assert.ok(tokens.promptGuidelines?.some((line) => line.includes("registration metadata")));
671
+ assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /token-optimized JSON shape/);
616
672
  });
617
673
 
618
674
  test("reference tools register agent-oriented descriptions and schema details", () => {
@@ -624,7 +680,7 @@ test("reference tools register agent-oriented descriptions and schema details",
624
680
  assert.ok(filesReferences, "missing files-references tool");
625
681
  assert.ok(references, "missing references tool");
626
682
 
627
- assert.match(filesReferences.description ?? "", /request, summary, repository, files, and execution sections/);
683
+ assert.match(filesReferences.description ?? "", /summary, repository, files, and execution sections/);
628
684
  assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Numeric contract:")));
629
685
  assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Behavior contract:")));
630
686
  assert.match(String((filesReferences.parameters as { description?: string } | undefined)?.description ?? ""), /structured Doxygen fields/);
@@ -644,11 +700,11 @@ test("find tools register agent-oriented descriptions and schema details", () =>
644
700
  assert.ok(filesFind, "missing files-find tool");
645
701
  assert.ok(find, "missing find tool");
646
702
 
647
- assert.match(filesFind.description ?? "", /LLM-oriented JSON payload/);
703
+ assert.match(filesFind.description ?? "", /token-optimized JSON payload/);
648
704
  assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Regex rule:")));
649
705
  assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Supported tags [Typescript]:")));
650
706
  assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Supported tags [Python]:")));
651
- assert.match(String((filesFind.parameters as { description?: string } | undefined)?.description ?? ""), /supported_tags_by_language/);
707
+ assert.match(String((filesFind.parameters as { description?: string } | undefined)?.description ?? ""), /Static supported-tag matrices are documented in tool registration metadata/);
652
708
 
653
709
  assert.match(find.description ?? "", /configured project source directories/);
654
710
  assert.ok(find.promptGuidelines?.some((line) => line.includes("source_directory_paths")));
@@ -665,7 +721,7 @@ test("compression tools register agent-oriented descriptions and schema details"
665
721
  assert.ok(filesCompress, "missing files-compress tool");
666
722
  assert.ok(compress, "missing compress tool");
667
723
 
668
- assert.match(filesCompress.description ?? "", /request, summary, repository, files, and execution sections/);
724
+ assert.match(filesCompress.description ?? "", /summary, repository, files, and execution sections/);
669
725
  assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Line-number behavior:")));
670
726
  assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Behavior contract:")));
671
727
  assert.match(String((filesCompress.parameters as { description?: string } | undefined)?.description ?? ""), /structured compressed lines/);
@@ -676,7 +732,7 @@ test("compression tools register agent-oriented descriptions and schema details"
676
732
  assert.match(String((compress.parameters as { description?: string } | undefined)?.description ?? ""), /structured compressed lines/);
677
733
  });
678
734
 
679
- test("files-tokens returns structured source facts and separated guidance", async () => {
735
+ test("files-tokens returns token-optimized structured source facts", async () => {
680
736
  const { projectBase } = initFixtureRepo({
681
737
  fixtures: [],
682
738
  docs: {
@@ -693,10 +749,6 @@ test("files-tokens returns structured source facts and separated guidance", asyn
693
749
  }) as {
694
750
  content?: Array<{ type: string; text?: string }>;
695
751
  details?: {
696
- request: {
697
- base_dir_path: string;
698
- requested_input_paths: string[];
699
- };
700
752
  summary: {
701
753
  counted_file_count: number;
702
754
  skipped_file_count: number;
@@ -709,14 +761,8 @@ test("files-tokens returns structured source facts and separated guidance", asyn
709
761
  line_count: number;
710
762
  byte_count: number;
711
763
  primary_heading_text?: string;
764
+ error_message?: string;
712
765
  }>;
713
- guidance: {
714
- source_observations: {
715
- skipped_inputs: Array<{ input_path: string; canonical_path: string; reason: string }>;
716
- };
717
- derived_recommendations: Array<{ kind: string; ordered_paths: string[] }>;
718
- actionable_next_steps: Array<{ kind: string; ordered_paths: string[] }>;
719
- };
720
766
  execution: {
721
767
  stderr: string;
722
768
  };
@@ -725,8 +771,6 @@ test("files-tokens returns structured source facts and separated guidance", asyn
725
771
 
726
772
  const payload = result.details!;
727
773
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
728
- assert.equal(payload.request.base_dir_path, projectBase);
729
- assert.deepEqual(payload.request.requested_input_paths, [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`, `${DEFAULT_DOCS_DIR}/MISSING.md`]);
730
774
  assert.equal(payload.summary.counted_file_count, 1);
731
775
  assert.equal(payload.summary.skipped_file_count, 1);
732
776
  assert.equal(payload.files[0]?.canonical_path, `${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`);
@@ -734,17 +778,54 @@ test("files-tokens returns structured source facts and separated guidance", asyn
734
778
  assert.equal(payload.files[0]?.end_line_number, payload.files[0]?.line_count);
735
779
  assert.ok((payload.files[0]?.byte_count ?? 0) > 0);
736
780
  assert.equal(payload.files[0]?.primary_heading_text, "Requirements");
737
- assert.deepEqual(payload.guidance.source_observations.skipped_inputs, [
738
- { input_path: `${DEFAULT_DOCS_DIR}/MISSING.md`, canonical_path: `${DEFAULT_DOCS_DIR}/MISSING.md`, reason: "not found" },
739
- ]);
740
- assert.equal(payload.guidance.derived_recommendations[0]?.kind, "prioritize_high_token_paths");
741
- assert.equal(payload.guidance.actionable_next_steps[0]?.kind, "read_top_token_paths_first");
781
+ assert.equal(payload.files[1]?.canonical_path, `${DEFAULT_DOCS_DIR}/MISSING.md`);
782
+ assert.equal(payload.files[1]?.error_message, "not found");
742
783
  assert.match(payload.execution.stderr, new RegExp(`skipped: ${DEFAULT_DOCS_DIR.replace("/", "\\/")}/MISSING\\.md: not found`));
743
784
  } finally {
744
785
  fs.rmSync(projectBase, { recursive: true, force: true });
745
786
  }
746
787
  });
747
788
 
789
+ test("files-tokens defers js-tiktoken loading until execution and returns structured dependency failures", async () => {
790
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
791
+ const missingModuleError = new Error("Cannot find module 'js-tiktoken'");
792
+ setJsTiktokenModuleLoaderForTests(() => {
793
+ throw missingModuleError;
794
+ });
795
+ try {
796
+ const pi = createFakePi();
797
+ assert.doesNotThrow(() => piUsereqExtension(pi));
798
+ const result = await executeRegisteredTool(pi, "files-tokens", projectBase, {
799
+ files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`],
800
+ }) as {
801
+ details?: {
802
+ summary: {
803
+ counted_file_count: number;
804
+ skipped_file_count: number;
805
+ processable_file_count: number;
806
+ };
807
+ files: unknown[];
808
+ execution: {
809
+ code: number;
810
+ stderr: string;
811
+ };
812
+ };
813
+ };
814
+
815
+ const payload = result.details!;
816
+ assert.equal(payload.summary.processable_file_count, 0);
817
+ assert.equal(payload.summary.counted_file_count, 0);
818
+ assert.equal(payload.summary.skipped_file_count, 0);
819
+ assert.deepEqual(payload.files, []);
820
+ assert.equal(payload.execution.code, 1);
821
+ assert.match(payload.execution.stderr, /js-tiktoken/);
822
+ assert.match(payload.execution.stderr, /npm ci/);
823
+ } finally {
824
+ setJsTiktokenModuleLoaderForTests(undefined);
825
+ fs.rmSync(projectBase, { recursive: true, force: true });
826
+ }
827
+ });
828
+
748
829
  test("files-references returns structured repository, symbol, and Doxygen facts", async () => {
749
830
  const { projectBase } = initFixtureRepo({ fixtures: [] });
750
831
  const samplePath = path.join(projectBase, "src", "sample.ts");
@@ -771,9 +852,6 @@ export function buildSample(input: string): string {
771
852
  }) as {
772
853
  content?: Array<{ type: string; text?: string }>;
773
854
  details?: {
774
- request: {
775
- requested_input_paths: string[];
776
- };
777
855
  summary: {
778
856
  analyzed_file_count: number;
779
857
  skipped_file_count: number;
@@ -808,7 +886,6 @@ export function buildSample(input: string): string {
808
886
 
809
887
  const payload = result.details!;
810
888
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
811
- assert.deepEqual(payload.request.requested_input_paths, ["src/sample.ts", "src/missing.ts"]);
812
889
  assert.equal(payload.summary.analyzed_file_count, 1);
813
890
  assert.equal(payload.summary.skipped_file_count, 1);
814
891
  assert.deepEqual(payload.repository.file_canonical_paths, ["src/sample.ts"]);
@@ -912,10 +989,6 @@ export function buildSample(input: string): string {
912
989
  }) as {
913
990
  content?: Array<{ type: string; text?: string }>;
914
991
  details?: {
915
- request: {
916
- line_number_mode: string;
917
- requested_input_paths: string[];
918
- };
919
992
  summary: {
920
993
  compressed_file_count: number;
921
994
  skipped_file_count: number;
@@ -950,8 +1023,6 @@ export function buildSample(input: string): string {
950
1023
 
951
1024
  const payload = result.details!;
952
1025
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
953
- assert.equal(payload.request.line_number_mode, "enabled");
954
- assert.deepEqual(payload.request.requested_input_paths, ["src/sample.ts", "src/missing.ts"]);
955
1026
  assert.equal(payload.summary.compressed_file_count, 1);
956
1027
  assert.equal(payload.summary.skipped_file_count, 1);
957
1028
  assert.equal(payload.summary.total_symbol_count, 1);
@@ -999,10 +1070,6 @@ test("compress returns a structured repository-scoped compression payload", asyn
999
1070
  }) as {
1000
1071
  content?: Array<{ type: string; text?: string }>;
1001
1072
  details?: {
1002
- request: {
1003
- scope: string;
1004
- line_number_mode: string;
1005
- };
1006
1073
  summary: {
1007
1074
  compressed_file_count: number;
1008
1075
  total_compressed_line_count: number;
@@ -1026,8 +1093,6 @@ test("compress returns a structured repository-scoped compression payload", asyn
1026
1093
 
1027
1094
  const payload = result.details!;
1028
1095
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1029
- assert.equal(payload.request.scope, "configured-source-directories");
1030
- assert.equal(payload.request.line_number_mode, "disabled");
1031
1096
  assert.equal(payload.summary.compressed_file_count, 2);
1032
1097
  assert.ok((payload.summary.total_compressed_line_count ?? 0) >= 2);
1033
1098
  assert.deepEqual(payload.repository.source_directory_paths, ["src"]);
@@ -1071,12 +1136,6 @@ export function buildSample(input: string): string {
1071
1136
  }) as {
1072
1137
  content?: Array<{ type: string; text?: string }>;
1073
1138
  details?: {
1074
- request: {
1075
- tag_filter_values: string[];
1076
- tag_filter_status: string;
1077
- line_number_mode: string;
1078
- requested_input_paths: string[];
1079
- };
1080
1139
  summary: {
1081
1140
  search_status: string;
1082
1141
  matched_file_count: number;
@@ -1084,7 +1143,7 @@ export function buildSample(input: string): string {
1084
1143
  total_match_count: number;
1085
1144
  };
1086
1145
  repository: {
1087
- supported_tags_by_language: Record<string, string[]>;
1146
+ file_canonical_paths: string[];
1088
1147
  };
1089
1148
  files: Array<{
1090
1149
  canonical_path: string;
@@ -1117,15 +1176,11 @@ export function buildSample(input: string): string {
1117
1176
 
1118
1177
  const payload = result.details!;
1119
1178
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1120
- assert.deepEqual(payload.request.tag_filter_values, ["FUNCTION"]);
1121
- assert.equal(payload.request.tag_filter_status, "valid");
1122
- assert.equal(payload.request.line_number_mode, "enabled");
1123
- assert.deepEqual(payload.request.requested_input_paths, ["src/sample.ts", "src/missing.ts"]);
1124
1179
  assert.equal(payload.summary.search_status, "matched");
1125
1180
  assert.equal(payload.summary.matched_file_count, 1);
1126
1181
  assert.equal(payload.summary.skipped_file_count, 1);
1127
1182
  assert.equal(payload.summary.total_match_count, 1);
1128
- assert.ok(payload.repository.supported_tags_by_language.typescript?.includes("FUNCTION"));
1183
+ assert.deepEqual(payload.repository.file_canonical_paths, ["src/sample.ts", "src/missing.ts"]);
1129
1184
  assert.deepEqual(payload.files.map((file) => file.status), ["matched", "skipped"]);
1130
1185
  assert.equal(payload.files[0]?.canonical_path, "src/sample.ts");
1131
1186
  assert.equal(payload.files[0]?.match_count, 1);
@@ -1182,11 +1237,6 @@ export function buildAlpha(): number {
1182
1237
  }) as {
1183
1238
  content?: Array<{ type: string; text?: string }>;
1184
1239
  details?: {
1185
- request: {
1186
- scope: string;
1187
- regex_status: string;
1188
- requested_file_count: number;
1189
- };
1190
1240
  summary: {
1191
1241
  search_status: string;
1192
1242
  matched_file_count: number;
@@ -1196,7 +1246,6 @@ export function buildAlpha(): number {
1196
1246
  repository: {
1197
1247
  source_directory_paths: string[];
1198
1248
  file_canonical_paths: string[];
1199
- supported_tags_by_language: Record<string, string[]>;
1200
1249
  };
1201
1250
  files: Array<{
1202
1251
  canonical_path: string;
@@ -1216,16 +1265,12 @@ export function buildAlpha(): number {
1216
1265
 
1217
1266
  const payload = result.details!;
1218
1267
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1219
- assert.equal(payload.request.scope, "configured-source-directories");
1220
- assert.equal(payload.request.regex_status, "valid");
1221
- assert.equal(payload.request.requested_file_count, 2);
1222
1268
  assert.equal(payload.summary.search_status, "matched");
1223
1269
  assert.equal(payload.summary.matched_file_count, 1);
1224
1270
  assert.equal(payload.summary.no_match_file_count, 1);
1225
1271
  assert.equal(payload.summary.total_match_count, 1);
1226
1272
  assert.deepEqual(payload.repository.source_directory_paths, ["src"]);
1227
1273
  assert.deepEqual(payload.repository.file_canonical_paths, ["src/alpha.ts", "src/nested/beta.ts"]);
1228
- assert.ok(payload.repository.supported_tags_by_language.typescript?.includes("FUNCTION"));
1229
1274
  assert.deepEqual(payload.files.map((file) => file.status), ["matched", "no_match"]);
1230
1275
  assert.equal(payload.files[0]?.matches[0]?.symbol_name, "buildAlpha");
1231
1276
  assert.match(payload.files[0]?.matches[0]?.code_lines[0]?.display_text ?? "", /^export function buildAlpha/);
@@ -1257,31 +1302,28 @@ test("path, static-check, git, docs, and worktree tools return structured JSON p
1257
1302
  content?: Array<{ text?: string }>;
1258
1303
  details?: {
1259
1304
  result: { path_value: string; path_present: boolean };
1260
- runtime_paths: { git_path: string };
1261
1305
  execution: { code: number };
1262
1306
  };
1263
1307
  };
1264
1308
  assert.deepEqual(JSON.parse(gitPathResult.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(gitPathResult.details)));
1265
- assert.equal(gitPathResult.details?.result.path_value, gitPathResult.details?.runtime_paths.git_path);
1309
+ assert.equal(gitPathResult.details?.result.path_value, projectBase);
1266
1310
  assert.equal(gitPathResult.details?.result.path_present, true);
1267
1311
  assert.equal(gitPathResult.details?.execution.code, 0);
1268
1312
 
1269
1313
  const basePathResult = await executeRegisteredTool(pi, "get-base-path", projectBase) as {
1270
1314
  details?: {
1271
1315
  result: { path_value: string };
1272
- runtime_paths: { base_path: string };
1273
1316
  execution: { code: number };
1274
1317
  };
1275
1318
  };
1276
- assert.equal(basePathResult.details?.result.path_value, basePathResult.details?.runtime_paths.base_path);
1319
+ assert.equal(basePathResult.details?.result.path_value, projectBase);
1277
1320
  assert.equal(basePathResult.details?.execution.code, 0);
1278
1321
 
1279
1322
  const gitCheckResult = await executeRegisteredTool(pi, "git-check", projectBase) as {
1280
- details?: { result: { status: string; worktree_status: string; head_status: string }; execution: { code: number } };
1323
+ details?: { result: { git_path_present: boolean; status: string }; execution: { code: number } };
1281
1324
  };
1325
+ assert.equal(gitCheckResult.details?.result.git_path_present, true);
1282
1326
  assert.equal(gitCheckResult.details?.result.status, "clean");
1283
- assert.equal(gitCheckResult.details?.result.worktree_status, "clean");
1284
- assert.equal(gitCheckResult.details?.result.head_status, "valid");
1285
1327
  assert.equal(gitCheckResult.details?.execution.code, 0);
1286
1328
 
1287
1329
  const docsCheckResult = await executeRegisteredTool(pi, "docs-check", projectBase) as {
@@ -1317,43 +1359,34 @@ test("path, static-check, git, docs, and worktree tools return structured JSON p
1317
1359
 
1318
1360
  const staticCheckResult = await executeRegisteredTool(pi, "static-check", projectBase) as {
1319
1361
  details?: {
1320
- request: { selection_directory_paths: string[]; excluded_directory_paths: string[] };
1321
1362
  summary: { selected_file_count: number };
1322
1363
  files: Array<{ canonical_path: string; status: string }>;
1323
1364
  execution: { code: number };
1324
1365
  };
1325
1366
  };
1326
- assert.deepEqual(staticCheckResult.details?.request.selection_directory_paths, ["src", "tests"]);
1327
- assert.ok(staticCheckResult.details?.request.excluded_directory_paths.includes("tests/fixtures"));
1328
1367
  assert.equal(staticCheckResult.details?.summary.selected_file_count, 1);
1329
1368
  assert.equal(staticCheckResult.details?.files[0]?.canonical_path, "src/check.ts");
1330
1369
  assert.equal(staticCheckResult.details?.files[0]?.status, "selected");
1331
1370
  assert.equal(staticCheckResult.details?.execution.code, 0);
1332
1371
 
1333
1372
  const gitWtNameResult = await executeRegisteredTool(pi, "git-wt-name", projectBase) as {
1334
- details?: { result: { worktree_name?: string; format_text: string; status: string }; execution: { code: number } };
1373
+ details?: { result: { worktree_name?: string }; execution: { code: number } };
1335
1374
  };
1336
1375
  const wtName = gitWtNameResult.details?.result.worktree_name ?? "";
1337
- assert.equal(gitWtNameResult.details?.result.status, "generated");
1338
- assert.equal(gitWtNameResult.details?.result.format_text, "useReq-<project>-<sanitized-branch>-<YYYYMMDDHHMMSS>");
1339
1376
  assert.match(wtName, /^useReq-/);
1340
1377
  assert.equal(gitWtNameResult.details?.execution.code, 0);
1341
1378
 
1342
1379
  const gitWtCreateResult = await executeRegisteredTool(pi, "git-wt-create", projectBase, { wtName }) as {
1343
- details?: { result: { status: string; worktree_name: string; branch_name: string; worktree_path: string }; execution: { code: number } };
1380
+ details?: { result: { worktree_name: string; worktree_path: string }; execution: { code: number } };
1344
1381
  };
1345
- assert.equal(gitWtCreateResult.details?.result.status, "created");
1346
1382
  assert.equal(gitWtCreateResult.details?.result.worktree_name, wtName);
1347
- assert.equal(gitWtCreateResult.details?.result.branch_name, wtName);
1348
1383
  assert.match(gitWtCreateResult.details?.result.worktree_path ?? "", new RegExp(`${wtName}$`));
1349
1384
  assert.equal(gitWtCreateResult.details?.execution.code, 0);
1350
1385
 
1351
1386
  const gitWtDeleteResult = await executeRegisteredTool(pi, "git-wt-delete", projectBase, { wtName }) as {
1352
- details?: { result: { status: string; worktree_name: string; branch_name: string; worktree_path: string }; execution: { code: number } };
1387
+ details?: { result: { worktree_name: string; worktree_path: string }; execution: { code: number } };
1353
1388
  };
1354
- assert.equal(gitWtDeleteResult.details?.result.status, "deleted");
1355
1389
  assert.equal(gitWtDeleteResult.details?.result.worktree_name, wtName);
1356
- assert.equal(gitWtDeleteResult.details?.result.branch_name, wtName);
1357
1390
  assert.match(gitWtDeleteResult.details?.result.worktree_path ?? "", new RegExp(`${wtName}$`));
1358
1391
  assert.equal(gitWtDeleteResult.details?.execution.code, 0);
1359
1392
  } finally {
@@ -1402,35 +1435,142 @@ test("configuration menus expose show-config ordering and omit overview or notif
1402
1435
  piUsereqExtension(pi);
1403
1436
  const command = pi.commands.get("pi-usereq");
1404
1437
  assert.ok(command);
1405
- const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Back", "Save and close"] });
1438
+ const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Save and close", "Save and close"] });
1406
1439
 
1407
1440
  await command!.handler("", ctx);
1408
1441
 
1409
1442
  const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1410
1443
  assert.deepEqual(ctx.__state.selectCalls[0]?.items ?? [], [
1411
- "docs-dir",
1412
- "tests-dir",
1413
- "src-dir",
1414
- "static-check",
1415
- "startup tools",
1416
- "notifications",
1417
- "show-config",
1444
+ "Document directory",
1445
+ "Source-code directories",
1446
+ "Unit tests directory",
1447
+ "Language static code checkers",
1448
+ "Enable tools",
1449
+ "Notifications",
1450
+ "Show configuration",
1418
1451
  "Reset defaults",
1419
1452
  "Save and close",
1420
1453
  ]);
1421
- assert.ok(renderedMenu.includes(formatRuntimePathForDisplay(getProjectConfigPath(cwd))));
1454
+ assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1455
+ assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1456
+ assert.doesNotMatch(renderedMenu, /beep:/);
1422
1457
  assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
1423
- "Toggle beep on success",
1424
- "Toggle beep on escape",
1425
- "Toggle beep on error",
1426
- "Selected notify command",
1458
+ "Enable notification",
1459
+ "Notification events",
1460
+ "Notify command",
1461
+ "Enable sound",
1462
+ "Sound events",
1427
1463
  "Sound toggle hotkey bind",
1428
- "Notify command (low vol.)",
1429
- "Notify command (mid vol.)",
1430
- "Notify command (high vol.)",
1431
- "Pushover notifications",
1432
- "Back",
1464
+ "Sound command (low vol.)",
1465
+ "Sound command (mid vol.)",
1466
+ "Sound command (high vol.)",
1467
+ "Enable pushover",
1468
+ "Pushover events",
1469
+ "Pushover priority",
1470
+ "Pushover title",
1471
+ "Pushover text",
1472
+ "Pushover User Key/Delivery Group Key",
1473
+ "Pushover Token/API Token Key",
1474
+ "Reset defaults",
1475
+ "Save and close",
1476
+ ]);
1477
+ });
1478
+
1479
+ test("notifications menu preserves focus on toggled and edited rows", async () => {
1480
+ const cwd = createTempDir("pi-usereq-menu-focus-");
1481
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1482
+ const pi = createFakePi();
1483
+ piUsereqExtension(pi);
1484
+ const command = pi.commands.get("pi-usereq");
1485
+ assert.ok(command);
1486
+ const ctx = createFakeCtx(cwd, {
1487
+ selects: [
1488
+ "notifications",
1489
+ "Notification events",
1490
+ "Prompt failed",
1491
+ "Save and close",
1492
+ "Notify command",
1493
+ "Save and close",
1494
+ "Save and close",
1495
+ ],
1496
+ inputs: ["echo notify"],
1497
+ });
1498
+
1499
+ await command!.handler("", ctx);
1500
+
1501
+ const notificationCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notifications");
1502
+ assert.equal(notificationCalls[0]?.selectedChoiceId, "notify-enabled");
1503
+ assert.equal(notificationCalls[1]?.selectedChoiceId, "notification-events");
1504
+ assert.equal(notificationCalls[2]?.selectedChoiceId, "notify-command");
1505
+
1506
+ const notificationEventCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notification events");
1507
+ assert.deepEqual(notificationEventCalls[0]?.items ?? [], [
1508
+ "Prompt completed",
1509
+ "Prompt interrupted",
1510
+ "Prompt failed",
1511
+ "Reset defaults",
1512
+ "Save and close",
1433
1513
  ]);
1514
+ assert.equal(notificationEventCalls[0]?.selectedChoiceId, "notify-on-completed");
1515
+ assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1516
+ });
1517
+
1518
+ test("notifications reset defaults preserves non-notification settings", async () => {
1519
+ const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1520
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1521
+ const pi = createFakePi();
1522
+ piUsereqExtension(pi);
1523
+ const command = pi.commands.get("pi-usereq");
1524
+ assert.ok(command);
1525
+ const ctx = createFakeCtx(cwd, {
1526
+ selects: [
1527
+ "docs-dir",
1528
+ "notifications",
1529
+ "Enable notification",
1530
+ "Enable sound",
1531
+ "high",
1532
+ "Reset defaults",
1533
+ "Save and close",
1534
+ "Save and close",
1535
+ ],
1536
+ inputs: ["docs/custom"],
1537
+ });
1538
+
1539
+ await command!.handler("", ctx);
1540
+
1541
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1542
+ assert.equal(config["docs-dir"], "docs/custom");
1543
+ assert.equal(config["notify-enabled"], false);
1544
+ assert.equal(config["notify-sound"], "none");
1545
+ assert.equal(config["notify-pushover-enabled"], false);
1546
+ });
1547
+
1548
+ test("top-level reset defaults restores all configuration values", async () => {
1549
+ const cwd = createTempDir("pi-usereq-menu-reset-all-");
1550
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1551
+ const pi = createFakePi();
1552
+ piUsereqExtension(pi);
1553
+ const command = pi.commands.get("pi-usereq");
1554
+ assert.ok(command);
1555
+ const ctx = createFakeCtx(cwd, {
1556
+ selects: [
1557
+ "docs-dir",
1558
+ "notifications",
1559
+ "Enable notification",
1560
+ "Save and close",
1561
+ "Reset defaults",
1562
+ "Save and close",
1563
+ ],
1564
+ inputs: ["docs/custom"],
1565
+ });
1566
+
1567
+ await command!.handler("", ctx);
1568
+
1569
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1570
+ assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1571
+ assert.deepEqual(config["src-dir"], ["src"]);
1572
+ assert.equal(config["notify-enabled"], false);
1573
+ assert.equal(config["notify-sound"], "none");
1434
1574
  });
1435
1575
 
1436
1576
  test("configuration menus reuse CLI settings theme semantics", async () => {
@@ -1514,12 +1654,12 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1514
1654
  testsDir: "tests",
1515
1655
  srcDir: ["src", "foobar"],
1516
1656
  contextFilledCells: 0,
1517
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1657
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1518
1658
  }),
1519
1659
  );
1520
1660
  });
1521
1661
 
1522
- test("session_start renders the CLEAR overlay when context usage is empty", async () => {
1662
+ test("session_start renders the 0-percent context icon when context usage is empty", async () => {
1523
1663
  const cwd = createTempDir("pi-usereq-context-empty-status-");
1524
1664
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1525
1665
  const pi = createFakePi();
@@ -1539,7 +1679,7 @@ test("session_start renders the CLEAR overlay when context usage is empty", asyn
1539
1679
  srcDir: ["src"],
1540
1680
  contextFilledCells: 0,
1541
1681
  contextPercent: 0,
1542
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1682
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1543
1683
  }),
1544
1684
  );
1545
1685
  });
@@ -1606,12 +1746,12 @@ test("context hook refreshes context usage and rounds progress cells upward", as
1606
1746
  srcDir: ["src"],
1607
1747
  contextFilledCells: 2,
1608
1748
  contextPercent: 19.1,
1609
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1749
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1610
1750
  }),
1611
1751
  );
1612
1752
  });
1613
1753
 
1614
- test("context hook renders the FULL! overlay when usage exceeds ninety percent", async () => {
1754
+ test("context hook renders the full context icon when usage exceeds ninety percent", async () => {
1615
1755
  const cwd = createTempDir("pi-usereq-context-full-status-");
1616
1756
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1617
1757
  const contextUsage = { tokens: 910, contextWindow: 1000, percent: 91 };
@@ -1633,19 +1773,19 @@ test("context hook renders the FULL! overlay when usage exceeds ninety percent",
1633
1773
  srcDir: ["src"],
1634
1774
  contextFilledCells: 10,
1635
1775
  contextPercent: 91,
1636
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1776
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1637
1777
  }),
1638
1778
  );
1639
1779
  });
1640
1780
 
1641
- test("status FULL overlay uses only CLI-supported theme tokens", async () => {
1781
+ test("status overflow context icon uses only CLI-supported theme tokens", async () => {
1642
1782
  const cwd = createTempDir("pi-usereq-context-theme-contract-");
1643
1783
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1644
1784
  const usedColors = new Set<string>();
1645
1785
  const pi = createFakePi();
1646
1786
  piUsereqExtension(pi);
1647
1787
  const ctx = createFakeCtx(cwd, { selects: [] }, {
1648
- getContextUsage: () => ({ tokens: 910, contextWindow: 1000, percent: 91 }),
1788
+ getContextUsage: () => ({ tokens: 1250, contextWindow: 1000, percent: 125 }),
1649
1789
  theme: {
1650
1790
  fg(color: string, text: string) {
1651
1791
  assert.ok(["accent", "warning", "dim", "error"].includes(color));
@@ -1674,8 +1814,8 @@ test("status FULL overlay uses only CLI-supported theme tokens", async () => {
1674
1814
  testsDir: "tests",
1675
1815
  srcDir: ["src"],
1676
1816
  contextFilledCells: 10,
1677
- contextPercent: 91,
1678
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1817
+ contextPercent: 125,
1818
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1679
1819
  }),
1680
1820
  );
1681
1821
  });
@@ -1714,7 +1854,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1714
1854
  testsDir: "tests",
1715
1855
  srcDir: ["src"],
1716
1856
  contextFilledCells: 0,
1717
- et: " ⏱︎ 0:00 ⚑ --:-- ⌛︎ --:--",
1857
+ et: "⏱︎ 0:00 ⚑ --:-- ⌛︎--:--",
1718
1858
  }),
1719
1859
  );
1720
1860
 
@@ -1728,7 +1868,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1728
1868
  testsDir: "tests",
1729
1869
  srcDir: ["src"],
1730
1870
  contextFilledCells: 0,
1731
- et: " ⏱︎ 1:01 ⚑ --:-- ⌛︎ --:--",
1871
+ et: "⏱︎ 1:01 ⚑ --:-- ⌛︎--:--",
1732
1872
  }),
1733
1873
  );
1734
1874
 
@@ -1749,7 +1889,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1749
1889
  testsDir: "tests",
1750
1890
  srcDir: ["src"],
1751
1891
  contextFilledCells: 0,
1752
- et: " ⏱︎ --:-- ⚑ 1:01 ⌛︎ 1:01",
1892
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
1753
1893
  }),
1754
1894
  );
1755
1895
 
@@ -1773,7 +1913,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1773
1913
  testsDir: "tests",
1774
1914
  srcDir: ["src"],
1775
1915
  contextFilledCells: 0,
1776
- et: " ⏱︎ --:-- ⚑ 1:01 ⌛︎ 1:01",
1916
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
1777
1917
  }),
1778
1918
  );
1779
1919
  assert.ok(clearedHandles.length >= 2);
@@ -1801,12 +1941,23 @@ test("session_start enables default custom tools and default embedded tools only
1801
1941
  assert.equal(activeTools.has("ls"), false);
1802
1942
  });
1803
1943
 
1804
- test("default configuration enables all pi-notify beep outcomes", () => {
1944
+ test("default configuration applies the documented notify, sound, and pushover defaults", () => {
1805
1945
  const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
1806
1946
 
1807
- assert.equal(config["notify-beep-on-end"], true);
1808
- assert.equal(config["notify-beep-on-esc"], true);
1809
- assert.equal(config["notify-beep-on-error"], true);
1947
+ assert.equal(config["notify-enabled"], false);
1948
+ assert.equal(config["notify-on-completed"], true);
1949
+ assert.equal(config["notify-on-interrupted"], false);
1950
+ assert.equal(config["notify-on-failed"], false);
1951
+ assert.equal(config["notify-sound"], "none");
1952
+ assert.equal(config["notify-sound-on-completed"], true);
1953
+ assert.equal(config["notify-sound-on-interrupted"], false);
1954
+ assert.equal(config["notify-sound-on-failed"], false);
1955
+ assert.equal(config["notify-pushover-enabled"], false);
1956
+ assert.equal(config["notify-pushover-on-completed"], true);
1957
+ assert.equal(config["notify-pushover-on-interrupted"], false);
1958
+ assert.equal(config["notify-pushover-on-failed"], false);
1959
+ assert.equal(config.PI_NOTIFY_CMD, 'notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a "PI-useReq" "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"');
1960
+ assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1810
1961
  });
1811
1962
 
1812
1963
  test("configuration menu can enable embedded builtin tools", async () => {
@@ -1820,7 +1971,7 @@ test("configuration menu can enable embedded builtin tools", async () => {
1820
1971
  await command!.handler(
1821
1972
  "",
1822
1973
  createFakeCtx(cwd, {
1823
- selects: ["startup tools", "Toggle tool", "grep", "Back", "Save and close"],
1974
+ selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
1824
1975
  }),
1825
1976
  );
1826
1977
 
@@ -1840,7 +1991,7 @@ test("configuration menu can disable configurable active tools", async () => {
1840
1991
  await command!.handler(
1841
1992
  "",
1842
1993
  createFakeCtx(cwd, {
1843
- selects: ["startup tools", "Disable all configurable tools", "Back", "Save and close"],
1994
+ selects: ["Enable tools", "Disable all configurable tools", "Save and close", "Save and close"],
1844
1995
  }),
1845
1996
  );
1846
1997
 
@@ -1849,7 +2000,7 @@ test("configuration menu can disable configurable active tools", async () => {
1849
2000
  assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
1850
2001
  });
1851
2002
 
1852
- test("configuration menu can persist pi-notify settings", async () => {
2003
+ test("configuration menu can persist notify and sound settings", async () => {
1853
2004
  const cwd = createTempDir("pi-usereq-menu-notify-");
1854
2005
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1855
2006
  const pi = createFakePi();
@@ -1859,19 +2010,26 @@ test("configuration menu can persist pi-notify settings", async () => {
1859
2010
  const ctx = createFakeCtx(cwd, {
1860
2011
  selects: [
1861
2012
  "notifications",
1862
- "Toggle beep on success",
1863
- "Toggle beep on error",
1864
- "Selected notify command",
2013
+ "Enable notification",
2014
+ "Notification events",
2015
+ "Prompt failed",
2016
+ "Save and close",
2017
+ "Enable sound",
1865
2018
  "high",
2019
+ "Sound events",
2020
+ "Prompt interrupted",
2021
+ "Save and close",
1866
2022
  "Sound toggle hotkey bind",
1867
- "Notify command (low vol.)",
1868
- "Notify command (mid vol.)",
1869
- "Notify command (high vol.)",
1870
- "Back",
2023
+ "Notify command",
2024
+ "Sound command (low vol.)",
2025
+ "Sound command (mid vol.)",
2026
+ "Sound command (high vol.)",
2027
+ "Save and close",
1871
2028
  "Save and close",
1872
2029
  ],
1873
2030
  inputs: [
1874
2031
  "alt+shift+s",
2032
+ "notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a \"PI-useReq\" \"%%PROMT%% @ %%BASE%% [%%TIME%%]\" \"%%RESULT%%\"",
1875
2033
  "echo low %%INSTALLATION_PATH%%",
1876
2034
  "echo mid %%INSTALLATION_PATH%%",
1877
2035
  "echo high %%INSTALLATION_PATH%%",
@@ -1881,11 +2039,16 @@ test("configuration menu can persist pi-notify settings", async () => {
1881
2039
  await command!.handler("", ctx);
1882
2040
 
1883
2041
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1884
- assert.equal(config["notify-beep-on-end"], false);
1885
- assert.equal(config["notify-beep-on-error"], false);
1886
- assert.equal(config["notify-beep-on-esc"], true);
2042
+ assert.equal(config["notify-enabled"], true);
2043
+ assert.equal(config["notify-on-completed"], true);
2044
+ assert.equal(config["notify-on-interrupted"], false);
2045
+ assert.equal(config["notify-on-failed"], true);
1887
2046
  assert.equal(config["notify-sound"], "high");
2047
+ assert.equal(config["notify-sound-on-completed"], true);
2048
+ assert.equal(config["notify-sound-on-interrupted"], true);
2049
+ assert.equal(config["notify-sound-on-failed"], false);
1888
2050
  assert.equal(config["notify-sound-toggle-shortcut"], "alt+shift+s");
2051
+ assert.equal(config.PI_NOTIFY_CMD, "notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a \"PI-useReq\" \"%%PROMT%% @ %%BASE%% [%%TIME%%]\" \"%%RESULT%%\"");
1889
2052
  assert.equal(config.PI_NOTIFY_SOUND_LOW_CMD, "echo low %%INSTALLATION_PATH%%");
1890
2053
  assert.equal(config.PI_NOTIFY_SOUND_MID_CMD, "echo mid %%INSTALLATION_PATH%%");
1891
2054
  assert.equal(config.PI_NOTIFY_SOUND_HIGH_CMD, "echo high %%INSTALLATION_PATH%%");
@@ -1897,8 +2060,7 @@ test("configuration menu can persist pi-notify settings", async () => {
1897
2060
  testsDir: "tests",
1898
2061
  srcDir: ["src"],
1899
2062
  contextFilledCells: 0,
1900
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1901
- beep: "esc",
2063
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1902
2064
  sound: "high",
1903
2065
  }),
1904
2066
  );
@@ -1914,17 +2076,22 @@ test("configuration menu can persist pushover settings", async () => {
1914
2076
  const ctx = createFakeCtx(cwd, {
1915
2077
  selects: [
1916
2078
  "notifications",
1917
- "Pushover notifications",
1918
- "Notify on success",
1919
- "User Key/Delivery Group Key",
1920
- "Token/API Token Key",
1921
- "Priority",
1922
- "1=High Priority",
1923
- "Back",
1924
- "Back",
2079
+ "Enable pushover",
2080
+ "Pushover events",
2081
+ "Prompt failed",
2082
+ "Save and close",
2083
+ "Pushover priority",
2084
+ "High",
2085
+ "Pushover title",
2086
+ "Pushover text",
2087
+ "Pushover User Key/Delivery Group Key",
2088
+ "Pushover Token/API Token Key",
2089
+ "Save and close",
1925
2090
  "Save and close",
1926
2091
  ],
1927
2092
  inputs: [
2093
+ "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2094
+ "%%RESULT%%\n%%ARGS%%",
1928
2095
  "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
1929
2096
  "ah6bf5u2sj63mcvou6qamiabeoubbe",
1930
2097
  ],
@@ -1933,11 +2100,15 @@ test("configuration menu can persist pushover settings", async () => {
1933
2100
  await command!.handler("", ctx);
1934
2101
 
1935
2102
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1936
- assert.equal(config["notify-pushover-global-disable"], false);
1937
- assert.equal(config["notify-pushover-on-success"], true);
2103
+ assert.equal(config["notify-pushover-enabled"], true);
2104
+ assert.equal(config["notify-pushover-on-completed"], true);
2105
+ assert.equal(config["notify-pushover-on-interrupted"], false);
2106
+ assert.equal(config["notify-pushover-on-failed"], true);
1938
2107
  assert.equal(config["notify-pushover-user-key"], "gzfjjvp1xxmhibqwzh9m7i1zwvf83j");
1939
2108
  assert.equal(config["notify-pushover-api-token"], "ah6bf5u2sj63mcvou6qamiabeoubbe");
1940
2109
  assert.equal(config["notify-pushover-priority"], 1);
2110
+ assert.equal(config["notify-pushover-title"], "%%PROMT%% @ %%BASE%% [%%TIME%%]");
2111
+ assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1941
2112
  assert.equal(
1942
2113
  ctx.__state.statuses.get("pi-usereq"),
1943
2114
  buildExpectedFakeStatusText({
@@ -1946,13 +2117,13 @@ test("configuration menu can persist pushover settings", async () => {
1946
2117
  testsDir: "tests",
1947
2118
  srcDir: ["src"],
1948
2119
  contextFilledCells: 0,
1949
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1950
- pushover: "on",
2120
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2121
+ sound: "none",
1951
2122
  }),
1952
2123
  );
1953
2124
  });
1954
2125
 
1955
- test("successful req prompt completion sends pushover requests and suppresses invalid scenarios", async () => {
2126
+ test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
1956
2127
  const cwd = createTempDir("pi-usereq-pushover-run-");
1957
2128
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1958
2129
  const configPath = getProjectConfigPath(cwd);
@@ -1995,10 +2166,13 @@ test("successful req prompt completion sends pushover requests and suppresses in
1995
2166
 
1996
2167
  try {
1997
2168
  writeConfig({
1998
- "notify-pushover-on-success": true,
2169
+ "notify-pushover-enabled": true,
2170
+ "notify-pushover-on-completed": true,
1999
2171
  "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2000
2172
  "notify-pushover-api-token": "ah6bf5u2sj63mcvou6qamiabeoubbe",
2001
2173
  "notify-pushover-priority": 1,
2174
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2175
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
2002
2176
  });
2003
2177
  await pi.emit("session_start", { reason: "startup" }, ctx);
2004
2178
  recordedRequests.length = 0;
@@ -2022,8 +2196,8 @@ test("successful req prompt completion sends pushover requests and suppresses in
2022
2196
  assert.equal(successParams.get("token"), "ah6bf5u2sj63mcvou6qamiabeoubbe");
2023
2197
  assert.equal(successParams.get("user"), "gzfjjvp1xxmhibqwzh9m7i1zwvf83j");
2024
2198
  assert.equal(successParams.get("priority"), "1");
2025
- assert.equal(successParams.get("message"), "Inspect src/index.ts");
2026
- assert.equal(successParams.get("title"), `analyze @ ${buildExpectedFakeBasePath(cwd)} [0:05]`);
2199
+ assert.equal(successParams.get("message"), `${buildExpectedNotifyResult("stop")}\nInspect src/index.ts`);
2200
+ assert.equal(successParams.get("title"), `analyze @ ${buildExpectedNotifyBasePath(cwd)} [0:05]`);
2027
2201
 
2028
2202
  recordedRequests.length = 0;
2029
2203
  nowMs = 10_000;
@@ -2058,17 +2232,69 @@ test("successful req prompt completion sends pushover requests and suppresses in
2058
2232
  assert.equal(recordedRequests.length, 0);
2059
2233
 
2060
2234
  writeConfig({
2061
- "notify-pushover-on-success": true,
2235
+ "notify-pushover-enabled": true,
2236
+ "notify-pushover-on-interrupted": true,
2237
+ "notify-pushover-on-failed": true,
2062
2238
  "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2063
- "notify-pushover-api-token": "",
2239
+ "notify-pushover-api-token": "ah6bf5u2sj63mcvou6qamiabeoubbe",
2064
2240
  "notify-pushover-priority": 1,
2241
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2242
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
2065
2243
  });
2066
2244
  await pi.emit("session_start", { reason: "startup" }, ctx);
2067
2245
  recordedRequests.length = 0;
2068
2246
  nowMs = 30_000;
2069
- await pi.commands.get("req-write")!.handler("Write docs", ctx);
2247
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2070
2248
  await pi.emit("agent_start", {}, ctx);
2071
2249
  nowMs = 32_000;
2250
+ await pi.emit("agent_end", {
2251
+ messages: [
2252
+ {
2253
+ role: "assistant",
2254
+ stopReason: "aborted",
2255
+ content: [],
2256
+ },
2257
+ ],
2258
+ }, ctx);
2259
+ assert.equal(recordedRequests.length, 1);
2260
+ const abortedParams = new URLSearchParams(recordedRequests[0]?.body ?? "");
2261
+ assert.equal(abortedParams.get("title"), `change @ ${buildExpectedNotifyBasePath(cwd)} [0:02]`);
2262
+ assert.equal(abortedParams.get("message"), `${buildExpectedNotifyResult("aborted")}\nAdjust docs`);
2263
+
2264
+ recordedRequests.length = 0;
2265
+ nowMs = 40_000;
2266
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2267
+ await pi.emit("agent_start", {}, ctx);
2268
+ nowMs = 42_000;
2269
+ await pi.emit("agent_end", {
2270
+ messages: [
2271
+ {
2272
+ role: "assistant",
2273
+ stopReason: "error",
2274
+ content: [],
2275
+ },
2276
+ ],
2277
+ }, ctx);
2278
+ assert.equal(recordedRequests.length, 1);
2279
+ const errorParams = new URLSearchParams(recordedRequests[0]?.body ?? "");
2280
+ assert.equal(errorParams.get("title"), `write @ ${buildExpectedNotifyBasePath(cwd)} [0:02]`);
2281
+ assert.equal(errorParams.get("message"), `${buildExpectedNotifyResult("error")}\nWrite docs`);
2282
+
2283
+ writeConfig({
2284
+ "notify-pushover-enabled": true,
2285
+ "notify-pushover-on-completed": true,
2286
+ "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2287
+ "notify-pushover-api-token": "",
2288
+ "notify-pushover-priority": 1,
2289
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2290
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
2291
+ });
2292
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2293
+ recordedRequests.length = 0;
2294
+ nowMs = 50_000;
2295
+ await pi.commands.get("req-check")!.handler("Check docs", ctx);
2296
+ await pi.emit("agent_start", {}, ctx);
2297
+ nowMs = 53_000;
2072
2298
  await pi.emit("agent_end", {
2073
2299
  messages: [
2074
2300
  {
@@ -2085,7 +2311,7 @@ test("successful req prompt completion sends pushover requests and suppresses in
2085
2311
  }
2086
2312
  });
2087
2313
 
2088
- test("pushover global disable suppresses delivery without clearing enabled state", async () => {
2314
+ test("pushover global enable controls status and suppresses delivery when disabled", async () => {
2089
2315
  const cwd = createTempDir("pi-usereq-pushover-disabled-");
2090
2316
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2091
2317
  const pi = createFakePi();
@@ -2095,13 +2321,9 @@ test("pushover global disable suppresses delivery without clearing enabled state
2095
2321
  const ctx = createFakeCtx(cwd, {
2096
2322
  selects: [
2097
2323
  "notifications",
2098
- "Pushover notifications",
2099
- "Notify on success",
2100
- "Global disable",
2101
- "User Key/Delivery Group Key",
2102
- "Token/API Token Key",
2103
- "Back",
2104
- "Back",
2324
+ "Pushover User Key/Delivery Group Key",
2325
+ "Pushover Token/API Token Key",
2326
+ "Save and close",
2105
2327
  "Save and close",
2106
2328
  ],
2107
2329
  inputs: [
@@ -2113,8 +2335,8 @@ test("pushover global disable suppresses delivery without clearing enabled state
2113
2335
  await command!.handler("", ctx);
2114
2336
 
2115
2337
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2116
- assert.equal(config["notify-pushover-on-success"], true);
2117
- assert.equal(config["notify-pushover-global-disable"], true);
2338
+ assert.equal(config["notify-pushover-enabled"], false);
2339
+ assert.equal(config["notify-pushover-on-completed"], true);
2118
2340
  assert.equal(
2119
2341
  ctx.__state.statuses.get("pi-usereq"),
2120
2342
  buildExpectedFakeStatusText({
@@ -2123,8 +2345,8 @@ test("pushover global disable suppresses delivery without clearing enabled state
2123
2345
  testsDir: "tests",
2124
2346
  srcDir: ["src"],
2125
2347
  contextFilledCells: 0,
2126
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
2127
- pushover: "on",
2348
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2349
+ sound: "none",
2128
2350
  }),
2129
2351
  );
2130
2352
 
@@ -2171,6 +2393,201 @@ test("pushover global disable suppresses delivery without clearing enabled state
2171
2393
  }
2172
2394
  });
2173
2395
 
2396
+ test("command notify routes through PI_NOTIFY_CMD placeholders and per-event toggles", async () => {
2397
+ const cwd = createTempDir("pi-usereq-command-notify-");
2398
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2399
+ const configPath = getProjectConfigPath(cwd);
2400
+ const writeConfig = (overrides: Record<string, unknown>) => {
2401
+ const config = {
2402
+ ...getDefaultConfig(cwd),
2403
+ ...overrides,
2404
+ };
2405
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2406
+ };
2407
+ const pi = createFakePi();
2408
+ piUsereqExtension(pi);
2409
+ const ctx = createFakeCtx(cwd);
2410
+ const originalDateNow = Date.now;
2411
+ const recordedCommands: string[] = [];
2412
+ let nowMs = 0;
2413
+ setPiNotifySpawnForTests(((command: string, args?: readonly string[] | undefined, _options?: Record<string, unknown>) => {
2414
+ void command;
2415
+ recordedCommands.push(String(args && args.length > 0 ? args[args.length - 1] : ""));
2416
+ const fakeChild = {
2417
+ once(_eventName: string, _handler: (...handlerArgs: unknown[]) => void) {
2418
+ return fakeChild;
2419
+ },
2420
+ unref() {
2421
+ return undefined;
2422
+ },
2423
+ };
2424
+ return fakeChild as unknown as ReturnType<typeof import("node:child_process").spawn>;
2425
+ }) as typeof import("node:child_process").spawn);
2426
+ Date.now = () => nowMs;
2427
+
2428
+ try {
2429
+ writeConfig({
2430
+ "notify-enabled": true,
2431
+ "notify-on-completed": true,
2432
+ "notify-on-interrupted": false,
2433
+ "notify-on-failed": true,
2434
+ "notify-sound": "none",
2435
+ PI_NOTIFY_CMD: 'notify-send "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"',
2436
+ });
2437
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2438
+ recordedCommands.length = 0;
2439
+ nowMs = 0;
2440
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
2441
+ await pi.emit("agent_start", {}, ctx);
2442
+ nowMs = 5_000;
2443
+ await pi.emit("agent_end", {
2444
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2445
+ }, ctx);
2446
+
2447
+ assert.equal(recordedCommands.length, 1);
2448
+ assert.match(
2449
+ recordedCommands[0] ?? "",
2450
+ new RegExp(`analyze @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:05\\]`),
2451
+ );
2452
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("stop")));
2453
+
2454
+ recordedCommands.length = 0;
2455
+ nowMs = 10_000;
2456
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2457
+ await pi.emit("agent_start", {}, ctx);
2458
+ nowMs = 12_000;
2459
+ await pi.emit("agent_end", {
2460
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2461
+ }, ctx);
2462
+ assert.equal(recordedCommands.length, 0);
2463
+
2464
+ writeConfig({
2465
+ "notify-enabled": true,
2466
+ "notify-on-completed": true,
2467
+ "notify-on-interrupted": true,
2468
+ "notify-on-failed": true,
2469
+ "notify-sound": "none",
2470
+ PI_NOTIFY_CMD: 'notify-send "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"',
2471
+ });
2472
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2473
+ recordedCommands.length = 0;
2474
+ nowMs = 20_000;
2475
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2476
+ await pi.emit("agent_start", {}, ctx);
2477
+ nowMs = 22_000;
2478
+ await pi.emit("agent_end", {
2479
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2480
+ }, ctx);
2481
+ assert.equal(recordedCommands.length, 1);
2482
+ assert.match(
2483
+ recordedCommands[0] ?? "",
2484
+ new RegExp(`change @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:02\\]`),
2485
+ );
2486
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("aborted")));
2487
+
2488
+ recordedCommands.length = 0;
2489
+ nowMs = 30_000;
2490
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2491
+ await pi.emit("agent_start", {}, ctx);
2492
+ nowMs = 33_000;
2493
+ await pi.emit("agent_end", {
2494
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2495
+ }, ctx);
2496
+ assert.equal(recordedCommands.length, 1);
2497
+ assert.match(
2498
+ recordedCommands[0] ?? "",
2499
+ new RegExp(`write @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:03\\]`),
2500
+ );
2501
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("error")));
2502
+ } finally {
2503
+ setPiNotifySpawnForTests(undefined);
2504
+ Date.now = originalDateNow;
2505
+ }
2506
+ });
2507
+
2508
+ test("sound routing honors the selected sound state and per-event toggles", async () => {
2509
+ const cwd = createTempDir("pi-usereq-sound-routing-");
2510
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2511
+ const configPath = getProjectConfigPath(cwd);
2512
+ const writeConfig = (overrides: Record<string, unknown>) => {
2513
+ const config = {
2514
+ ...getDefaultConfig(cwd),
2515
+ ...overrides,
2516
+ };
2517
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2518
+ };
2519
+ const pi = createFakePi();
2520
+ piUsereqExtension(pi);
2521
+ const ctx = createFakeCtx(cwd);
2522
+ const recordedCommands: string[] = [];
2523
+ setPiNotifySpawnForTests(((command: string, args?: readonly string[] | undefined, _options?: Record<string, unknown>) => {
2524
+ void command;
2525
+ recordedCommands.push(String(args && args.length > 0 ? args[args.length - 1] : ""));
2526
+ const fakeChild = {
2527
+ once(_eventName: string, _handler: (...handlerArgs: unknown[]) => void) {
2528
+ return fakeChild;
2529
+ },
2530
+ unref() {
2531
+ return undefined;
2532
+ },
2533
+ };
2534
+ return fakeChild as unknown as ReturnType<typeof import("node:child_process").spawn>;
2535
+ }) as typeof import("node:child_process").spawn);
2536
+
2537
+ try {
2538
+ writeConfig({
2539
+ "notify-enabled": false,
2540
+ "notify-sound": "none",
2541
+ "notify-sound-on-completed": true,
2542
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2543
+ });
2544
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2545
+ recordedCommands.length = 0;
2546
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
2547
+ await pi.emit("agent_start", {}, ctx);
2548
+ await pi.emit("agent_end", {
2549
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2550
+ }, ctx);
2551
+ assert.deepEqual(recordedCommands, []);
2552
+
2553
+ writeConfig({
2554
+ "notify-enabled": false,
2555
+ "notify-sound": "high",
2556
+ "notify-sound-on-completed": false,
2557
+ "notify-sound-on-interrupted": true,
2558
+ "notify-sound-on-failed": false,
2559
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2560
+ });
2561
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2562
+ recordedCommands.length = 0;
2563
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2564
+ await pi.emit("agent_start", {}, ctx);
2565
+ await pi.emit("agent_end", {
2566
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2567
+ }, ctx);
2568
+ assert.equal(recordedCommands.length, 1);
2569
+
2570
+ writeConfig({
2571
+ "notify-enabled": false,
2572
+ "notify-sound": "high",
2573
+ "notify-sound-on-completed": false,
2574
+ "notify-sound-on-interrupted": false,
2575
+ "notify-sound-on-failed": true,
2576
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2577
+ });
2578
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2579
+ recordedCommands.length = 0;
2580
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2581
+ await pi.emit("agent_start", {}, ctx);
2582
+ await pi.emit("agent_end", {
2583
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2584
+ }, ctx);
2585
+ assert.equal(recordedCommands.length, 1);
2586
+ } finally {
2587
+ setPiNotifySpawnForTests(undefined);
2588
+ }
2589
+ });
2590
+
2174
2591
  test("sound toggle shortcut cycles persisted pi-notify sound levels", async () => {
2175
2592
  const cwd = createTempDir("pi-usereq-shortcut-notify-");
2176
2593
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -2200,7 +2617,7 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
2200
2617
  testsDir: "tests",
2201
2618
  srcDir: ["src"],
2202
2619
  contextFilledCells: 0,
2203
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
2620
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2204
2621
  sound: expectedSound,
2205
2622
  }),
2206
2623
  );
@@ -2219,11 +2636,11 @@ test("git-path tool derives the repository root at runtime", async () => {
2219
2636
  piUsereqExtension(pi);
2220
2637
  const result = await executeRegisteredTool(pi, "git-path", projectBase) as {
2221
2638
  content?: Array<{ text?: string }>;
2222
- details?: { result: { path_value: string }; runtime_paths: { git_path: string } };
2639
+ details?: { result: { path_value: string } };
2223
2640
  };
2224
2641
 
2225
2642
  assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(result.details)));
2226
- assert.equal(result.details?.result.path_value, result.details?.runtime_paths.git_path);
2643
+ assert.equal(result.details?.result.path_value, projectBase);
2227
2644
  } finally {
2228
2645
  fs.rmSync(projectBase, { recursive: true, force: true });
2229
2646
  }
@@ -2239,24 +2656,24 @@ test("static-check exposes the supported programming languages", () => {
2239
2656
  });
2240
2657
  });
2241
2658
 
2242
- test("configuration menu can add static-check entries from raw specs", async () => {
2659
+ test("configuration menu omits removed static-check raw-spec and reference actions", async () => {
2243
2660
  const cwd = createTempDir("pi-usereq-menu-sc-");
2244
2661
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2245
2662
  const pi = createFakePi();
2246
2663
  piUsereqExtension(pi);
2247
2664
  const command = pi.commands.get("pi-usereq");
2248
2665
  assert.ok(command);
2666
+ const ctx = createFakeCtx(cwd, {
2667
+ selects: ["static-check"],
2668
+ });
2249
2669
 
2250
- await command!.handler(
2251
- "",
2252
- createFakeCtx(cwd, {
2253
- selects: ["static-check", "Add entry from LANG=MODULE[,CMD[,PARAM...]]", "Back", "Save and close"],
2254
- inputs: ["Python=Command,true"],
2255
- }),
2256
- );
2670
+ await command!.handler("", ctx);
2257
2671
 
2258
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2259
- assert.deepEqual(config["static-check"].Python, [{ module: "Command", cmd: "true" }]);
2672
+ const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
2673
+ assert.match(renderedStaticCheckMenu, /Add static code checker/);
2674
+ assert.match(renderedStaticCheckMenu, /Remove static code checker/);
2675
+ assert.doesNotMatch(renderedStaticCheckMenu, /Add entry from LANG=MODULE\[,CMD\[,PARAM\.\.\.\]\]/);
2676
+ assert.doesNotMatch(renderedStaticCheckMenu, /Show supported languages/);
2260
2677
  });
2261
2678
 
2262
2679
  test("configuration menu hides removed static-check modules from user-facing actions", async () => {
@@ -2273,7 +2690,7 @@ test("configuration menu hides removed static-check modules from user-facing act
2273
2690
  await command!.handler("", ctx);
2274
2691
 
2275
2692
  const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
2276
- assert.match(renderedStaticCheckMenu, /Add entry for supported language/);
2693
+ assert.match(renderedStaticCheckMenu, /Add static code checker/);
2277
2694
  assert.doesNotMatch(renderedStaticCheckMenu, /Pylance/);
2278
2695
  assert.doesNotMatch(renderedStaticCheckMenu, /Ruff/);
2279
2696
  assert.doesNotMatch(renderedStaticCheckMenu, /Dummy/);
@@ -2292,9 +2709,9 @@ test("configuration menu can add guided static-check entries for explicit suppor
2292
2709
  createFakeCtx(cwd, {
2293
2710
  selects: [
2294
2711
  "static-check",
2295
- "Add entry for supported language",
2712
+ "Add static code checker",
2296
2713
  "Python",
2297
- "Back",
2714
+ "Save and close",
2298
2715
  "Save and close",
2299
2716
  ],
2300
2717
  inputs: ["true", ""],