@gotgenes/pi-autoformat 0.1.0 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.github/workflows/ci.yml +1 -3
  2. package/.github/workflows/release-please.yml +29 -0
  3. package/.markdownlint-cli2.yaml +14 -2
  4. package/.pi/extensions/pi-autoformat/config.json +3 -6
  5. package/.pi/prompts/README.md +59 -0
  6. package/.pi/prompts/plan-issue.md +64 -0
  7. package/.pi/prompts/retro.md +144 -0
  8. package/.pi/prompts/ship-issue.md +77 -0
  9. package/.pi/prompts/tdd-plan.md +67 -0
  10. package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
  11. package/.release-please-manifest.json +1 -1
  12. package/AGENTS.md +39 -0
  13. package/CHANGELOG.md +365 -0
  14. package/README.md +42 -109
  15. package/biome.json +1 -1
  16. package/docs/assets/logo.png +0 -0
  17. package/docs/assets/logo.svg +533 -0
  18. package/docs/configuration.md +358 -38
  19. package/docs/plans/0001-initial-implementation-plan.md +17 -9
  20. package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
  21. package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
  22. package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
  23. package/docs/plans/0010-acceptance-test-coverage.md +240 -0
  24. package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
  25. package/docs/plans/0013-fallback-chain-step-type.md +280 -0
  26. package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
  27. package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
  28. package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
  29. package/docs/plans/0022-pi-coding-agent-types.md +201 -0
  30. package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
  31. package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
  32. package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
  33. package/docs/retro/0013-fallback-chain-step-type.md +67 -0
  34. package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
  35. package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
  36. package/docs/retro/0022-pi-coding-agent-types.md +62 -0
  37. package/docs/testing.md +95 -0
  38. package/package.json +30 -11
  39. package/prek.toml +2 -2
  40. package/schemas/pi-autoformat.schema.json +145 -21
  41. package/src/builtin-formatters.ts +205 -0
  42. package/src/command-probe.ts +66 -0
  43. package/src/config-loader.ts +829 -90
  44. package/src/custom-mutation-tools.ts +125 -0
  45. package/src/extension.ts +469 -82
  46. package/src/format-scope.ts +118 -0
  47. package/src/formatter-config.ts +73 -36
  48. package/src/formatter-executor.ts +230 -34
  49. package/src/formatter-output-report.ts +149 -0
  50. package/src/formatter-registry.ts +139 -30
  51. package/src/index.ts +26 -5
  52. package/src/prompt-autoformatter.ts +148 -23
  53. package/src/shell-mutation-detector.ts +572 -0
  54. package/src/touched-files-queue.ts +72 -11
  55. package/test/acceptance-event-bus.test.ts +138 -0
  56. package/test/acceptance.test.ts +69 -0
  57. package/test/builtin-formatters.test.ts +382 -0
  58. package/test/command-probe.test.ts +79 -0
  59. package/test/config-loader.test.ts +640 -21
  60. package/test/custom-mutation-tools.test.ts +190 -0
  61. package/test/extension.test.ts +1535 -158
  62. package/test/fallback-acceptance.test.ts +98 -0
  63. package/test/fixtures/event-bus-emitter.ts +26 -0
  64. package/test/fixtures/formatter-recorder.mjs +25 -0
  65. package/test/format-scope.test.ts +139 -0
  66. package/test/formatter-config.test.ts +56 -5
  67. package/test/formatter-executor.test.ts +555 -35
  68. package/test/formatter-output-report.test.ts +178 -0
  69. package/test/formatter-registry.test.ts +330 -37
  70. package/test/helpers/rpc.ts +146 -0
  71. package/test/prompt-autoformatter.test.ts +315 -22
  72. package/test/schema.test.ts +149 -0
  73. package/test/shell-mutation-detector.test.ts +221 -0
  74. package/test/touched-files-queue.test.ts +40 -1
  75. package/test/types/theme-stub.test-d.ts +42 -0
@@ -2,13 +2,41 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
+ import { BUILTIN_FORMATTERS } from "./builtin-formatters.js";
6
+ import type { CustomMutationToolSpec } from "./custom-mutation-tools.js";
7
+ import type { FormatScopeSetting } from "./format-scope.js";
5
8
  import {
6
9
  type AutoformatConfig,
7
10
  createFormatterConfig,
8
- type FormatMode,
11
+ DEFAULT_FORMATTER_CONFIG,
12
+ type EventBusMutationChannelConfig,
13
+ type FormatterOutputOnFailure,
14
+ type FormatterOutputReportingConfig,
9
15
  type UserFormatterConfig,
10
16
  } from "./formatter-config.js";
11
- import type { FormatterDefinition } from "./formatter-registry.js";
17
+ import type {
18
+ ChainStep,
19
+ FallbackChainStep,
20
+ FormatterDefinition,
21
+ } from "./formatter-registry.js";
22
+
23
+ // Pi's built-in tool names. Declaring any of these in customMutationTools is
24
+ // a configuration mistake: write/edit are already covered, bash has its own
25
+ // detection path (see plan 0004), and the rest do not mutate files.
26
+ const BUILTIN_TOOL_NAMES = new Set([
27
+ "bash",
28
+ "edit",
29
+ "write",
30
+ "read",
31
+ "grep",
32
+ "find",
33
+ "ls",
34
+ ]);
35
+
36
+ import type {
37
+ ShellMutationDetectionConfig,
38
+ WrapperConfig,
39
+ } from "./shell-mutation-detector.js";
12
40
 
13
41
  export const AUTOFORMAT_EXTENSION_ID = "pi-autoformat";
14
42
  export const AUTOFORMAT_CONFIG_FILE_NAME = "config.json";
@@ -48,24 +76,6 @@ function pushIssue(
48
76
  issues.push({ path, message, sourcePath });
49
77
  }
50
78
 
51
- function validateFormatMode(
52
- value: unknown,
53
- issues: ConfigValidationIssue[],
54
- sourcePath?: string,
55
- ): FormatMode | undefined {
56
- if (value === "tool" || value === "prompt" || value === "session") {
57
- return value;
58
- }
59
-
60
- pushIssue(
61
- issues,
62
- "formatMode",
63
- 'Expected one of "tool", "prompt", or "session".',
64
- sourcePath,
65
- );
66
- return undefined;
67
- }
68
-
69
79
  function validateCommandTimeoutMs(
70
80
  value: unknown,
71
81
  issues: ConfigValidationIssue[],
@@ -132,39 +142,6 @@ function validateStringArray(
132
142
  return normalized;
133
143
  }
134
144
 
135
- function validateExtensionArray(
136
- fieldPath: string,
137
- value: unknown,
138
- issues: ConfigValidationIssue[],
139
- sourcePath?: string,
140
- ): string[] | undefined {
141
- const extensions = validateStringArray(fieldPath, value, issues, sourcePath);
142
- if (!extensions) {
143
- return undefined;
144
- }
145
-
146
- const normalized: string[] = [];
147
- for (let index = 0; index < extensions.length; index += 1) {
148
- const extension = extensions[index];
149
- if (!extension.startsWith(".")) {
150
- pushIssue(
151
- issues,
152
- `${fieldPath}[${index}]`,
153
- 'Expected a file extension beginning with ".".',
154
- sourcePath,
155
- );
156
- return undefined;
157
- }
158
-
159
- const lowercased = extension.toLowerCase();
160
- if (!normalized.includes(lowercased)) {
161
- normalized.push(lowercased);
162
- }
163
- }
164
-
165
- return normalized;
166
- }
167
-
168
145
  function validateEnvironment(
169
146
  fieldPath: string,
170
147
  value: unknown,
@@ -211,23 +188,40 @@ function validateFormatterDefinition(
211
188
  }
212
189
 
213
190
  const definition: Partial<FormatterDefinition> = {};
191
+ let commandProvided = false;
214
192
 
215
193
  for (const [key, entry] of Object.entries(value)) {
216
194
  if (key === "command") {
217
- definition.command = validateStringArray(
195
+ commandProvided = true;
196
+ const command = validateStringArray(
218
197
  `${fieldPath}.command`,
219
198
  entry,
220
199
  issues,
221
200
  sourcePath,
222
201
  );
202
+ if (command) {
203
+ const offendingIndex = command.findIndex((arg) =>
204
+ arg.includes("$FILE"),
205
+ );
206
+ if (offendingIndex >= 0) {
207
+ pushIssue(
208
+ issues,
209
+ `${fieldPath}.command`,
210
+ "$FILE substitution is no longer supported. Remove $FILE; file paths are appended to the command automatically. See docs/configuration.md.",
211
+ sourcePath,
212
+ );
213
+ } else {
214
+ definition.command = command;
215
+ }
216
+ }
223
217
  continue;
224
218
  }
225
219
 
226
220
  if (key === "extensions") {
227
- definition.extensions = validateExtensionArray(
228
- `${fieldPath}.extensions`,
229
- entry,
221
+ pushIssue(
230
222
  issues,
223
+ `${fieldPath}.extensions`,
224
+ "Deprecated. Remove this field; dispatch is driven by `chains`. The value is ignored.",
231
225
  sourcePath,
232
226
  );
233
227
  continue;
@@ -261,8 +255,8 @@ function validateFormatterDefinition(
261
255
  );
262
256
  }
263
257
 
264
- if (!definition.command || !definition.extensions) {
265
- if (!definition.command) {
258
+ if (!definition.command) {
259
+ if (!commandProvided) {
266
260
  pushIssue(
267
261
  issues,
268
262
  `${fieldPath}.command`,
@@ -270,23 +264,19 @@ function validateFormatterDefinition(
270
264
  sourcePath,
271
265
  );
272
266
  }
273
- if (!definition.extensions) {
274
- pushIssue(
275
- issues,
276
- `${fieldPath}.extensions`,
277
- "Missing required property.",
278
- sourcePath,
279
- );
280
- }
281
267
  return undefined;
282
268
  }
283
269
 
284
- return {
270
+ const resolved: FormatterDefinition = {
285
271
  command: definition.command,
286
- extensions: definition.extensions,
287
- environment: definition.environment,
288
- disabled: definition.disabled,
289
272
  };
273
+ if (definition.environment !== undefined) {
274
+ resolved.environment = definition.environment;
275
+ }
276
+ if (definition.disabled !== undefined) {
277
+ resolved.disabled = definition.disabled;
278
+ }
279
+ return resolved;
290
280
  }
291
281
 
292
282
  function validateFormatters(
@@ -301,6 +291,14 @@ function validateFormatters(
301
291
 
302
292
  const formatters: Record<string, FormatterDefinition> = {};
303
293
  for (const [formatterName, formatterValue] of Object.entries(value)) {
294
+ if (Object.hasOwn(BUILTIN_FORMATTERS, formatterName)) {
295
+ pushIssue(
296
+ issues,
297
+ `formatters.${formatterName}`,
298
+ `Shadows the built-in "${formatterName}" formatter. The user-declared definition will be used; remove this entry to fall back to the built-in.`,
299
+ sourcePath,
300
+ );
301
+ }
304
302
  const definition = validateFormatterDefinition(
305
303
  formatterName,
306
304
  formatterValue,
@@ -315,40 +313,651 @@ function validateFormatters(
315
313
  return formatters;
316
314
  }
317
315
 
318
- function validateChains(
316
+ function validateFallbackStep(
317
+ fieldPath: string,
318
+ value: Record<string, unknown>,
319
+ issues: ConfigValidationIssue[],
320
+ sourcePath?: string,
321
+ knownFormatterNames?: Set<string>,
322
+ ): FallbackChainStep | undefined {
323
+ let fallbackValue: unknown;
324
+ let hasUnknown = false;
325
+ for (const [key, entry] of Object.entries(value)) {
326
+ if (key === "fallback") {
327
+ fallbackValue = entry;
328
+ continue;
329
+ }
330
+ pushIssue(
331
+ issues,
332
+ `${fieldPath}.${key}`,
333
+ "Unknown fallback step property.",
334
+ sourcePath,
335
+ );
336
+ hasUnknown = true;
337
+ }
338
+
339
+ if (!Array.isArray(fallbackValue) || fallbackValue.length === 0) {
340
+ pushIssue(
341
+ issues,
342
+ `${fieldPath}.fallback`,
343
+ "Expected a non-empty array of formatter names.",
344
+ sourcePath,
345
+ );
346
+ return undefined;
347
+ }
348
+
349
+ const names: string[] = [];
350
+ let nameError = false;
351
+ for (let index = 0; index < fallbackValue.length; index += 1) {
352
+ const entry = fallbackValue[index];
353
+ if (typeof entry !== "string" || entry.length === 0) {
354
+ pushIssue(
355
+ issues,
356
+ `${fieldPath}.fallback[${index}]`,
357
+ "Expected a non-empty string.",
358
+ sourcePath,
359
+ );
360
+ return undefined;
361
+ }
362
+ if (knownFormatterNames && !knownFormatterNames.has(entry)) {
363
+ pushIssue(
364
+ issues,
365
+ `${fieldPath}.fallback[${index}]`,
366
+ `Unknown formatter name "${entry}". Declare it in \`formatters\` or remove it from the fallback group.`,
367
+ sourcePath,
368
+ );
369
+ nameError = true;
370
+ continue;
371
+ }
372
+ names.push(entry);
373
+ }
374
+
375
+ if (hasUnknown || nameError) {
376
+ return undefined;
377
+ }
378
+
379
+ return { fallback: names };
380
+ }
381
+
382
+ function validateChainStep(
383
+ fieldPath: string,
319
384
  value: unknown,
320
385
  issues: ConfigValidationIssue[],
321
386
  sourcePath?: string,
322
- ): Record<string, string[]> | undefined {
387
+ knownFormatterNames?: Set<string>,
388
+ ): ChainStep | undefined {
389
+ if (typeof value === "string") {
390
+ if (value.length === 0) {
391
+ pushIssue(
392
+ issues,
393
+ fieldPath,
394
+ "Expected a non-empty formatter name.",
395
+ sourcePath,
396
+ );
397
+ return undefined;
398
+ }
399
+ if (knownFormatterNames && !knownFormatterNames.has(value)) {
400
+ pushIssue(
401
+ issues,
402
+ fieldPath,
403
+ `Unknown formatter name "${value}". Declare it in \`formatters\` or remove it from \`chains\`.`,
404
+ sourcePath,
405
+ );
406
+ return undefined;
407
+ }
408
+ return value;
409
+ }
410
+
411
+ if (isRecord(value)) {
412
+ return validateFallbackStep(
413
+ fieldPath,
414
+ value,
415
+ issues,
416
+ sourcePath,
417
+ knownFormatterNames,
418
+ );
419
+ }
420
+
421
+ pushIssue(
422
+ issues,
423
+ fieldPath,
424
+ 'Expected a formatter name (string) or a fallback group ({ "fallback": [name, ...] }).',
425
+ sourcePath,
426
+ );
427
+ return undefined;
428
+ }
429
+
430
+ function validateChains(
431
+ value: unknown,
432
+ issues: ConfigValidationIssue[],
433
+ sourcePath: string | undefined,
434
+ knownFormatterNames: Set<string>,
435
+ ): Record<string, ChainStep[]> | undefined {
323
436
  if (!isRecord(value)) {
324
437
  pushIssue(issues, "chains", "Expected an object.", sourcePath);
325
438
  return undefined;
326
439
  }
327
440
 
328
- const chains: Record<string, string[]> = {};
441
+ const chains: Record<string, ChainStep[]> = {};
329
442
  for (const [extension, chainValue] of Object.entries(value)) {
330
- if (!extension.startsWith(".")) {
443
+ if (extension !== "*" && !extension.startsWith(".")) {
444
+ pushIssue(
445
+ issues,
446
+ `chains.${extension}`,
447
+ 'Expected a file extension key beginning with "." or the wildcard "*".',
448
+ sourcePath,
449
+ );
450
+ continue;
451
+ }
452
+
453
+ if (!Array.isArray(chainValue) || chainValue.length === 0) {
331
454
  pushIssue(
332
455
  issues,
333
456
  `chains.${extension}`,
334
- 'Expected a file extension key beginning with ".".',
457
+ "Expected a non-empty array of chain steps.",
458
+ sourcePath,
459
+ );
460
+ continue;
461
+ }
462
+
463
+ const steps: ChainStep[] = [];
464
+ let stepError = false;
465
+ for (let index = 0; index < chainValue.length; index += 1) {
466
+ const step = validateChainStep(
467
+ `chains.${extension}[${index}]`,
468
+ chainValue[index],
469
+ issues,
335
470
  sourcePath,
471
+ knownFormatterNames,
336
472
  );
473
+ if (!step) {
474
+ stepError = true;
475
+ continue;
476
+ }
477
+ steps.push(step);
478
+ }
479
+
480
+ if (stepError) {
481
+ continue;
482
+ }
483
+ // The wildcard key is preserved verbatim; only extensions are lowercased.
484
+ chains[extension === "*" ? "*" : extension.toLowerCase()] = steps;
485
+ }
486
+
487
+ return chains;
488
+ }
489
+
490
+ function validateFormatScope(
491
+ value: unknown,
492
+ issues: ConfigValidationIssue[],
493
+ sourcePath?: string,
494
+ ): FormatScopeSetting | undefined {
495
+ if (value === "repoRoot" || value === "cwd") {
496
+ return value;
497
+ }
498
+ if (Array.isArray(value)) {
499
+ const result: string[] = [];
500
+ for (let index = 0; index < value.length; index += 1) {
501
+ const entry = value[index];
502
+ if (typeof entry !== "string" || entry.length === 0) {
503
+ pushIssue(
504
+ issues,
505
+ `formatScope[${index}]`,
506
+ "Expected a non-empty string.",
507
+ sourcePath,
508
+ );
509
+ return undefined;
510
+ }
511
+ result.push(entry);
512
+ }
513
+ return result;
514
+ }
515
+ pushIssue(
516
+ issues,
517
+ "formatScope",
518
+ 'Expected "repoRoot", "cwd", or an array of paths.',
519
+ sourcePath,
520
+ );
521
+ return undefined;
522
+ }
523
+
524
+ function validateWrapper(
525
+ fieldPath: string,
526
+ value: unknown,
527
+ issues: ConfigValidationIssue[],
528
+ sourcePath?: string,
529
+ ): WrapperConfig | undefined {
530
+ if (!isRecord(value)) {
531
+ pushIssue(issues, fieldPath, "Expected an object.", sourcePath);
532
+ return undefined;
533
+ }
534
+ const wrapper: Partial<WrapperConfig> = {};
535
+ for (const [key, entry] of Object.entries(value)) {
536
+ if (key === "prefix") {
537
+ if (typeof entry !== "string" || entry.length === 0) {
538
+ pushIssue(
539
+ issues,
540
+ `${fieldPath}.prefix`,
541
+ "Expected a non-empty string.",
542
+ sourcePath,
543
+ );
544
+ return undefined;
545
+ }
546
+ wrapper.prefix = entry;
337
547
  continue;
338
548
  }
549
+ if (key === "outputFormat") {
550
+ if (entry !== "lines") {
551
+ pushIssue(
552
+ issues,
553
+ `${fieldPath}.outputFormat`,
554
+ 'Expected "lines".',
555
+ sourcePath,
556
+ );
557
+ return undefined;
558
+ }
559
+ wrapper.outputFormat = entry;
560
+ continue;
561
+ }
562
+ pushIssue(
563
+ issues,
564
+ `${fieldPath}.${key}`,
565
+ "Unknown wrapper property.",
566
+ sourcePath,
567
+ );
568
+ }
569
+ if (!wrapper.prefix) {
570
+ pushIssue(
571
+ issues,
572
+ `${fieldPath}.prefix`,
573
+ "Missing required property.",
574
+ sourcePath,
575
+ );
576
+ return undefined;
577
+ }
578
+ return { prefix: wrapper.prefix, outputFormat: wrapper.outputFormat };
579
+ }
339
580
 
340
- const chain = validateStringArray(
341
- `chains.${extension}`,
342
- chainValue,
581
+ function validateShellMutationDetection(
582
+ value: unknown,
583
+ issues: ConfigValidationIssue[],
584
+ sourcePath?: string,
585
+ ): Partial<ShellMutationDetectionConfig> | undefined {
586
+ if (!isRecord(value)) {
587
+ pushIssue(
343
588
  issues,
589
+ "shellMutationDetection",
590
+ "Expected an object.",
344
591
  sourcePath,
345
592
  );
346
- if (chain) {
347
- chains[extension.toLowerCase()] = chain;
593
+ return undefined;
594
+ }
595
+ const result: Partial<ShellMutationDetectionConfig> = {};
596
+ for (const [key, entry] of Object.entries(value)) {
597
+ if (key === "enabled") {
598
+ const enabled = validateBooleanField(
599
+ "shellMutationDetection.enabled",
600
+ entry,
601
+ issues,
602
+ sourcePath,
603
+ );
604
+ if (enabled !== undefined) {
605
+ result.enabled = enabled;
606
+ }
607
+ continue;
348
608
  }
609
+ if (key === "argumentParsing") {
610
+ const argumentParsing = validateBooleanField(
611
+ "shellMutationDetection.argumentParsing",
612
+ entry,
613
+ issues,
614
+ sourcePath,
615
+ );
616
+ if (argumentParsing !== undefined) {
617
+ result.argumentParsing = argumentParsing;
618
+ }
619
+ continue;
620
+ }
621
+ if (key === "snapshotGlobs") {
622
+ if (!Array.isArray(entry)) {
623
+ pushIssue(
624
+ issues,
625
+ "shellMutationDetection.snapshotGlobs",
626
+ "Expected an array of strings.",
627
+ sourcePath,
628
+ );
629
+ continue;
630
+ }
631
+ const globs: string[] = [];
632
+ let valid = true;
633
+ for (let index = 0; index < entry.length; index += 1) {
634
+ const glob = entry[index];
635
+ if (typeof glob !== "string" || glob.length === 0) {
636
+ pushIssue(
637
+ issues,
638
+ `shellMutationDetection.snapshotGlobs[${index}]`,
639
+ "Expected a non-empty string.",
640
+ sourcePath,
641
+ );
642
+ valid = false;
643
+ break;
644
+ }
645
+ globs.push(glob);
646
+ }
647
+ if (valid) {
648
+ result.snapshotGlobs = globs;
649
+ }
650
+ continue;
651
+ }
652
+ if (key === "wrappers") {
653
+ if (!Array.isArray(entry)) {
654
+ pushIssue(
655
+ issues,
656
+ "shellMutationDetection.wrappers",
657
+ "Expected an array.",
658
+ sourcePath,
659
+ );
660
+ continue;
661
+ }
662
+ const wrappers: WrapperConfig[] = [];
663
+ let valid = true;
664
+ for (let index = 0; index < entry.length; index += 1) {
665
+ const wrapper = validateWrapper(
666
+ `shellMutationDetection.wrappers[${index}]`,
667
+ entry[index],
668
+ issues,
669
+ sourcePath,
670
+ );
671
+ if (!wrapper) {
672
+ valid = false;
673
+ break;
674
+ }
675
+ wrappers.push(wrapper);
676
+ }
677
+ if (valid) {
678
+ result.wrappers = wrappers;
679
+ }
680
+ continue;
681
+ }
682
+ pushIssue(
683
+ issues,
684
+ `shellMutationDetection.${key}`,
685
+ "Unknown property.",
686
+ sourcePath,
687
+ );
349
688
  }
689
+ return result;
690
+ }
350
691
 
351
- return chains;
692
+ function validateCustomMutationToolEntry(
693
+ fieldPath: string,
694
+ value: unknown,
695
+ issues: ConfigValidationIssue[],
696
+ sourcePath: string | undefined,
697
+ seenToolNames: Set<string>,
698
+ ): CustomMutationToolSpec | undefined {
699
+ if (!isRecord(value)) {
700
+ pushIssue(issues, fieldPath, "Expected an object.", sourcePath);
701
+ return undefined;
702
+ }
703
+
704
+ let toolName: string | undefined;
705
+ let pathField: string | undefined;
706
+ let pathFields: string[] | undefined;
707
+ let hasUnknown = false;
708
+
709
+ for (const [key, entry] of Object.entries(value)) {
710
+ if (key === "toolName") {
711
+ if (typeof entry !== "string" || entry.length === 0) {
712
+ pushIssue(
713
+ issues,
714
+ `${fieldPath}.toolName`,
715
+ "Expected a non-empty string.",
716
+ sourcePath,
717
+ );
718
+ return undefined;
719
+ }
720
+ if (BUILTIN_TOOL_NAMES.has(entry)) {
721
+ pushIssue(
722
+ issues,
723
+ `${fieldPath}.toolName`,
724
+ `"${entry}" is a Pi built-in tool and cannot be declared as a custom mutation tool. Built-in mutating tools (write, edit) are already covered; others do not mutate files.`,
725
+ sourcePath,
726
+ );
727
+ return undefined;
728
+ }
729
+ if (seenToolNames.has(entry)) {
730
+ pushIssue(
731
+ issues,
732
+ `${fieldPath}.toolName`,
733
+ `Duplicate toolName "${entry}". Each tool may only be declared once.`,
734
+ sourcePath,
735
+ );
736
+ return undefined;
737
+ }
738
+ toolName = entry;
739
+ continue;
740
+ }
741
+
742
+ if (key === "pathField") {
743
+ if (typeof entry !== "string" || entry.length === 0) {
744
+ pushIssue(
745
+ issues,
746
+ `${fieldPath}.pathField`,
747
+ "Expected a non-empty string.",
748
+ sourcePath,
749
+ );
750
+ return undefined;
751
+ }
752
+ pathField = entry;
753
+ continue;
754
+ }
755
+
756
+ if (key === "pathFields") {
757
+ if (!Array.isArray(entry) || entry.length === 0) {
758
+ pushIssue(
759
+ issues,
760
+ `${fieldPath}.pathFields`,
761
+ "Expected a non-empty array of strings.",
762
+ sourcePath,
763
+ );
764
+ return undefined;
765
+ }
766
+ const collected: string[] = [];
767
+ for (let index = 0; index < entry.length; index += 1) {
768
+ const item = entry[index];
769
+ if (typeof item !== "string" || item.length === 0) {
770
+ pushIssue(
771
+ issues,
772
+ `${fieldPath}.pathFields[${index}]`,
773
+ "Expected a non-empty string.",
774
+ sourcePath,
775
+ );
776
+ return undefined;
777
+ }
778
+ collected.push(item);
779
+ }
780
+ pathFields = collected;
781
+ continue;
782
+ }
783
+
784
+ pushIssue(issues, `${fieldPath}.${key}`, "Unknown property.", sourcePath);
785
+ hasUnknown = true;
786
+ }
787
+
788
+ if (hasUnknown) {
789
+ return undefined;
790
+ }
791
+
792
+ if (!toolName) {
793
+ pushIssue(
794
+ issues,
795
+ `${fieldPath}.toolName`,
796
+ "Missing required property.",
797
+ sourcePath,
798
+ );
799
+ return undefined;
800
+ }
801
+
802
+ const hasField = pathField !== undefined;
803
+ const hasFields = pathFields !== undefined;
804
+ if (hasField === hasFields) {
805
+ pushIssue(
806
+ issues,
807
+ fieldPath,
808
+ "Expected exactly one of `pathField` or `pathFields`.",
809
+ sourcePath,
810
+ );
811
+ return undefined;
812
+ }
813
+
814
+ seenToolNames.add(toolName);
815
+ return pathField !== undefined
816
+ ? { toolName, pathField }
817
+ : { toolName, pathFields: pathFields as string[] };
818
+ }
819
+
820
+ function validateCustomMutationTools(
821
+ value: unknown,
822
+ issues: ConfigValidationIssue[],
823
+ sourcePath?: string,
824
+ ): CustomMutationToolSpec[] | undefined {
825
+ if (!Array.isArray(value)) {
826
+ pushIssue(
827
+ issues,
828
+ "customMutationTools",
829
+ "Expected an array of mutation tool specs.",
830
+ sourcePath,
831
+ );
832
+ return undefined;
833
+ }
834
+
835
+ const seenToolNames = new Set<string>();
836
+ const collected: CustomMutationToolSpec[] = [];
837
+ let hasError = false;
838
+ for (let index = 0; index < value.length; index += 1) {
839
+ const entry = validateCustomMutationToolEntry(
840
+ `customMutationTools[${index}]`,
841
+ value[index],
842
+ issues,
843
+ sourcePath,
844
+ seenToolNames,
845
+ );
846
+ if (!entry) {
847
+ hasError = true;
848
+ continue;
849
+ }
850
+ collected.push(entry);
851
+ }
852
+
853
+ if (hasError) {
854
+ return undefined;
855
+ }
856
+ return collected;
857
+ }
858
+
859
+ function validateFormatterOutput(
860
+ value: unknown,
861
+ issues: ConfigValidationIssue[],
862
+ sourcePath?: string,
863
+ ): Partial<FormatterOutputReportingConfig> | undefined {
864
+ if (!isRecord(value)) {
865
+ pushIssue(issues, "formatterOutput", "Expected an object.", sourcePath);
866
+ return undefined;
867
+ }
868
+
869
+ const result: Partial<FormatterOutputReportingConfig> = {};
870
+ for (const [key, entry] of Object.entries(value)) {
871
+ if (key === "onFailure") {
872
+ if (entry === "none" || entry === "stderr" || entry === "both") {
873
+ result.onFailure = entry as FormatterOutputOnFailure;
874
+ continue;
875
+ }
876
+ pushIssue(
877
+ issues,
878
+ "formatterOutput.onFailure",
879
+ 'Expected one of "none", "stderr", or "both".',
880
+ sourcePath,
881
+ );
882
+ continue;
883
+ }
884
+ if (key === "maxBytes" || key === "maxLines") {
885
+ if (typeof entry === "number" && Number.isInteger(entry) && entry >= 0) {
886
+ result[key] = entry;
887
+ continue;
888
+ }
889
+ pushIssue(
890
+ issues,
891
+ `formatterOutput.${key}`,
892
+ "Expected a non-negative integer.",
893
+ sourcePath,
894
+ );
895
+ continue;
896
+ }
897
+ pushIssue(
898
+ issues,
899
+ `formatterOutput.${key}`,
900
+ "Unknown property.",
901
+ sourcePath,
902
+ );
903
+ }
904
+
905
+ if (Object.keys(result).length === 0) {
906
+ return undefined;
907
+ }
908
+ return result;
909
+ }
910
+
911
+ function validateEventBusMutationChannel(
912
+ value: unknown,
913
+ issues: ConfigValidationIssue[],
914
+ sourcePath?: string,
915
+ ): Partial<EventBusMutationChannelConfig> | undefined {
916
+ if (!isRecord(value)) {
917
+ pushIssue(
918
+ issues,
919
+ "eventBusMutationChannel",
920
+ "Expected an object.",
921
+ sourcePath,
922
+ );
923
+ return undefined;
924
+ }
925
+
926
+ const result: Partial<EventBusMutationChannelConfig> = {};
927
+ for (const [key, entry] of Object.entries(value)) {
928
+ if (key === "enabled") {
929
+ const enabled = validateBooleanField(
930
+ "eventBusMutationChannel.enabled",
931
+ entry,
932
+ issues,
933
+ sourcePath,
934
+ );
935
+ if (enabled !== undefined) {
936
+ result.enabled = enabled;
937
+ }
938
+ continue;
939
+ }
940
+ if (key === "channel") {
941
+ if (typeof entry !== "string" || entry.length === 0) {
942
+ pushIssue(
943
+ issues,
944
+ "eventBusMutationChannel.channel",
945
+ "Expected a non-empty string.",
946
+ sourcePath,
947
+ );
948
+ continue;
949
+ }
950
+ result.channel = entry;
951
+ continue;
952
+ }
953
+ pushIssue(
954
+ issues,
955
+ `eventBusMutationChannel.${key}`,
956
+ "Unknown property.",
957
+ sourcePath,
958
+ );
959
+ }
960
+ return result;
352
961
  }
353
962
 
354
963
  function validateConfigObject(
@@ -363,6 +972,9 @@ function validateConfigObject(
363
972
  return { config, issues };
364
973
  }
365
974
 
975
+ // Two passes: validate everything except chains first so we can build the
976
+ // known-formatter-name set (built-ins + this file's formatters) before
977
+ // validating chains' formatter references.
366
978
  for (const [key, entry] of Object.entries(value)) {
367
979
  if (key === "$schema") {
368
980
  if (typeof entry !== "string") {
@@ -372,10 +984,22 @@ function validateConfigObject(
372
984
  }
373
985
 
374
986
  if (key === "formatMode") {
375
- const formatMode = validateFormatMode(entry, issues, sourcePath);
376
- if (formatMode) {
377
- config.formatMode = formatMode;
378
- }
987
+ pushIssue(
988
+ issues,
989
+ "formatMode",
990
+ "formatMode has been removed; prompt-end formatting is now the only mode.",
991
+ sourcePath,
992
+ );
993
+ continue;
994
+ }
995
+
996
+ if (key === "notifyAgent") {
997
+ pushIssue(
998
+ issues,
999
+ "notifyAgent",
1000
+ "notifyAgent has been removed; the extension now notifies via steering messages at turn end.",
1001
+ sourcePath,
1002
+ );
379
1003
  continue;
380
1004
  }
381
1005
 
@@ -413,9 +1037,58 @@ function validateConfigObject(
413
1037
  }
414
1038
 
415
1039
  if (key === "chains") {
416
- const chains = validateChains(entry, issues, sourcePath);
417
- if (chains) {
418
- config.chains = chains;
1040
+ // Defer chains until we know which formatter names are valid in this file.
1041
+ continue;
1042
+ }
1043
+
1044
+ if (key === "formatScope") {
1045
+ const formatScope = validateFormatScope(entry, issues, sourcePath);
1046
+ if (formatScope !== undefined) {
1047
+ config.formatScope = formatScope;
1048
+ }
1049
+ continue;
1050
+ }
1051
+
1052
+ if (key === "shellMutationDetection") {
1053
+ const detection = validateShellMutationDetection(
1054
+ entry,
1055
+ issues,
1056
+ sourcePath,
1057
+ );
1058
+ if (detection !== undefined) {
1059
+ config.shellMutationDetection = detection;
1060
+ }
1061
+ continue;
1062
+ }
1063
+
1064
+ if (key === "customMutationTools") {
1065
+ const tools = validateCustomMutationTools(entry, issues, sourcePath);
1066
+ if (tools !== undefined) {
1067
+ config.customMutationTools = tools;
1068
+ }
1069
+ continue;
1070
+ }
1071
+
1072
+ if (key === "formatterOutput") {
1073
+ const formatterOutput = validateFormatterOutput(
1074
+ entry,
1075
+ issues,
1076
+ sourcePath,
1077
+ );
1078
+ if (formatterOutput !== undefined) {
1079
+ config.formatterOutput = formatterOutput;
1080
+ }
1081
+ continue;
1082
+ }
1083
+
1084
+ if (key === "eventBusMutationChannel") {
1085
+ const channel = validateEventBusMutationChannel(
1086
+ entry,
1087
+ issues,
1088
+ sourcePath,
1089
+ );
1090
+ if (channel !== undefined) {
1091
+ config.eventBusMutationChannel = channel;
419
1092
  }
420
1093
  continue;
421
1094
  }
@@ -423,6 +1096,23 @@ function validateConfigObject(
423
1096
  pushIssue(issues, key, "Unknown top-level property.", sourcePath);
424
1097
  }
425
1098
 
1099
+ if ("chains" in value) {
1100
+ const knownFormatterNames = new Set<string>([
1101
+ ...Object.keys(DEFAULT_FORMATTER_CONFIG.formatters),
1102
+ ...Object.keys(config.formatters ?? {}),
1103
+ ...Object.keys(BUILTIN_FORMATTERS),
1104
+ ]);
1105
+ const chains = validateChains(
1106
+ (value as Record<string, unknown>).chains,
1107
+ issues,
1108
+ sourcePath,
1109
+ knownFormatterNames,
1110
+ );
1111
+ if (chains) {
1112
+ config.chains = chains;
1113
+ }
1114
+ }
1115
+
426
1116
  return { config, issues };
427
1117
  }
428
1118
 
@@ -446,9 +1136,24 @@ function mergeUserConfigs(
446
1136
  overrides: UserFormatterConfig,
447
1137
  ): UserFormatterConfig {
448
1138
  return {
449
- formatMode: overrides.formatMode ?? base.formatMode,
450
1139
  commandTimeoutMs: overrides.commandTimeoutMs ?? base.commandTimeoutMs,
451
1140
  hideSummariesInTui: overrides.hideSummariesInTui ?? base.hideSummariesInTui,
1141
+ formatScope: overrides.formatScope ?? base.formatScope,
1142
+ shellMutationDetection: mergeShellMutationDetection(
1143
+ base.shellMutationDetection,
1144
+ overrides.shellMutationDetection,
1145
+ ),
1146
+ // Arrays replace wholesale (consistent with formatScope, snapshotGlobs).
1147
+ customMutationTools:
1148
+ overrides.customMutationTools ?? base.customMutationTools,
1149
+ eventBusMutationChannel: mergeEventBusMutationChannel(
1150
+ base.eventBusMutationChannel,
1151
+ overrides.eventBusMutationChannel,
1152
+ ),
1153
+ formatterOutput: mergeFormatterOutput(
1154
+ base.formatterOutput,
1155
+ overrides.formatterOutput,
1156
+ ),
452
1157
  formatters: {
453
1158
  ...base.formatters,
454
1159
  ...overrides.formatters,
@@ -460,6 +1165,40 @@ function mergeUserConfigs(
460
1165
  };
461
1166
  }
462
1167
 
1168
+ function mergeFormatterOutput(
1169
+ base: Partial<FormatterOutputReportingConfig> | undefined,
1170
+ overrides: Partial<FormatterOutputReportingConfig> | undefined,
1171
+ ): Partial<FormatterOutputReportingConfig> | undefined {
1172
+ if (!base && !overrides) {
1173
+ return undefined;
1174
+ }
1175
+ return { ...(base ?? {}), ...(overrides ?? {}) };
1176
+ }
1177
+
1178
+ function mergeEventBusMutationChannel(
1179
+ base: Partial<EventBusMutationChannelConfig> | undefined,
1180
+ overrides: Partial<EventBusMutationChannelConfig> | undefined,
1181
+ ): Partial<EventBusMutationChannelConfig> | undefined {
1182
+ if (!base && !overrides) {
1183
+ return undefined;
1184
+ }
1185
+ return { ...(base ?? {}), ...(overrides ?? {}) };
1186
+ }
1187
+
1188
+ function mergeShellMutationDetection(
1189
+ base: Partial<ShellMutationDetectionConfig> | undefined,
1190
+ overrides: Partial<ShellMutationDetectionConfig> | undefined,
1191
+ ): Partial<ShellMutationDetectionConfig> | undefined {
1192
+ if (!base && !overrides) {
1193
+ return undefined;
1194
+ }
1195
+ // Per AGENTS.md / plan: arrays replace, scalars override.
1196
+ return {
1197
+ ...(base ?? {}),
1198
+ ...(overrides ?? {}),
1199
+ };
1200
+ }
1201
+
463
1202
  export function getGlobalConfigPath(agentDir = defaultAgentDir()): string {
464
1203
  return join(
465
1204
  agentDir,