@aefree/pi-plastic 0.6.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.7.0] - 2026-09-16
11
+
12
+ ### Fixed
13
+
14
+ - Infer tool arguments from the schemas in `src/tool-definition.ts`, retaining required fields, optional fields, arrays, and literal enums instead of implicit `any` parameters.
15
+ - Use TypeBox schema guards and array-based unions in the adapter, and narrow Bash tool events directly in both safety guards.
16
+ - Add a pinned strict typecheck before the test suite, covering production sources, extensions, and compile-only argument-inference regressions.
17
+
18
+ ### Changed
19
+
20
+ - Present Plastic tools and capability search with compact action headers, operation targets, and structured result summaries in Pi's existing tool boxes.
21
+ - Keep previews, blocked checkins, cancelled switches, uncertain server effects, unavailable comparisons, and output omissions visible; retain full returned evidence and colored diff hunks on expansion.
22
+ - Use Pi's wrapping components and configured expansion hint instead of manual character truncation and a fixed shortcut. Preserve original JSON numeric text and mask common credential assignments in display output.
23
+ - Add an offline tool preview and behavioral renderer tests covering outcomes, exact evidence, redaction, keybindings, registration, and narrow terminals.
24
+
25
+ ## [0.6.1] - 2026-09-14
26
+
27
+ ### Fixed
28
+
29
+ - Clarify that `cm cat --file` writes to and can overwrite its destination, including when stdout is redirected. Route ordinary baseline reviews through typed diff tools, require new external temporary destinations for necessary manual exports, and distinguish changeset numbers from file revision IDs.
30
+
10
31
  ## [0.6.0] - 2026-09-14
11
32
 
12
33
  ### Added
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Pi tools, footer status, and skill guidance for Plastic SCM / Unity Version Control workflows.
4
4
 
5
+ ## Tool presentation
6
+
7
+ Plastic tools use compact action headers inside Pi's standard tool boxes. Headers show the workspace name and the branch, file, revision pair, or query being acted on; command previews are labeled explicitly. Collapsed results summarize structured status counts, diff availability, patch output, and server-merge outcomes. Blocked checkins, unresolved effects, omitted results, and unavailable comparisons remain visible without expansion. Plain CLI output stays neutral rather than implying a verified mutation.
8
+
9
+ Expand a result using your configured tool-expansion shortcut to see the workspace, request, colored diff hunks, and complete original returned evidence. The display preserves JSON identifiers and numeric text, masks common credential assignments, and removes terminal control sequences. Rendering does not change tool execution or model-facing output; it is not a general source-code secret scanner.
10
+
11
+ Run `npm run preview:tools` from a repository checkout for offline examples. The preview uses plain colors and a test expansion binding; Pi supplies the live theme, box, and configured shortcut.
12
+
5
13
  ## Plastic branch footer status
6
14
 
7
15
  When Pi starts inside a Plastic workspace, this package adds a themed `Plastic <branch>` status to Pi's built-in footer. It discovers the nearest enclosing `.plastic/plastic.workspace` and reads either the selector's `smartbranch` or `br` form as the credential-free branch source. A bounded `cm status` call is used only when the selector has no valid branch. Selector changes and successful same-workspace `plastic_*` tools refresh the status; sibling workspaces are ignored.
@@ -64,7 +72,7 @@ For controlled comparisons, set `PI_PLASTIC_TOOL_LOADING_MODE` before starting P
64
72
  ```bash
65
73
  PI_PLASTIC_TOOL_LOADING_MODE=balanced # default production candidate
66
74
  PI_PLASTIC_TOOL_LOADING_MODE=loader-only # maximum initial schema reduction
67
- PI_PLASTIC_TOOL_LOADING_MODE=all-active # all 29 currently exposed tools; loader omitted
75
+ PI_PLASTIC_TOOL_LOADING_MODE=all-active # all 30 currently exposed tools; loader omitted
68
76
  ```
69
77
 
70
78
  Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package's effective tools before deferring, restoring, or activating them. If canonical provenance or ownership of the effective loader cannot be proven, `pi-plastic` fails safe: it preserves the complete current active set exactly, does not defer, remove, or activate any `plastic_*` name, and an effective package loader can only report known tools that are already active rather than activating inactive names. On sourceInfo-capable Pi instances, providers without native deferred definitions still receive the complete current active set after a loader call. Reload or restart Pi after source edits; source files are not watched automatically.
@@ -111,13 +119,13 @@ pi install npm:@aefree/pi-plastic
111
119
  Install a pinned GitHub release over HTTPS:
112
120
 
113
121
  ```bash
114
- pi install git:github.com/aefreedman/pi-plastic@v0.6.0
122
+ pi install git:github.com/aefreedman/pi-plastic@v0.7.0
115
123
  ```
116
124
 
117
125
  Equivalent SSH install:
118
126
 
119
127
  ```bash
120
- pi install git:git@github.com/aefreedman/pi-plastic@v0.6.0
128
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.7.0
121
129
  ```
122
130
 
123
131
  To intentionally track the moving default branch instead of a release tag:
@@ -156,7 +164,9 @@ Project-local installation protects only Pi processes that load those project se
156
164
  npm test
157
165
  ```
158
166
 
159
- The default suite is credential-free and covers tool validation, path-resolution regressions, extension registration, OpenAI strict-schema compatibility classification, and bash guard behavior.
167
+ `npm test` first runs `npm run typecheck` with pinned TypeScript and Node 22 types. The strict, no-emit check covers `index.ts`, `src/`, every extension, and compile-only schema-inference contracts under `tests/types/`. Runtime tests and eval harnesses execute through `tsx`.
168
+
169
+ The default suite is credential-free and covers tool validation, schema conversion, path-resolution regressions, extension registration, rendering, OpenAI strict-schema compatibility classification, and bash guard behavior.
160
170
 
161
171
  Run the opt-in read-only live smoke test against a dedicated clean sandbox workspace:
162
172
 
@@ -191,6 +201,7 @@ OpenAI Codex models may advertise grammar tools without advertising strict JSON-
191
201
  - Shared workspace discovery and branch parsing live in `src/plastic-workspace.ts`.
192
202
  - `extensions/plastic-branch-status.ts` owns the additive footer status and its session-scoped refresh lifecycle.
193
203
  - `index.ts` is the Pi tool registration layer.
204
+ - `src/plastic-renderers.ts` owns compact calls, result summaries, and expanded evidence.
194
205
  - Output shapes are intentionally stable for prompt and workflow compatibility.
195
206
 
196
207
  ## License
@@ -42,7 +42,7 @@ export const __bashCmDiffGuardInternals = {
42
42
 
43
43
  export default function bashCmDiffGuard(pi: ExtensionAPI) {
44
44
  pi.on("tool_call", async (event, ctx) => {
45
- if (!isBashToolCall(event as { toolName?: string })) return;
45
+ if (!isBashToolCall(event)) return;
46
46
 
47
47
  const command = typeof event.input.command === "string" ? event.input.command : "";
48
48
  if (!command || !commandRunsCmDiff(command)) return;
@@ -36,7 +36,7 @@ export const __bashCmMergeGuardInternals = {
36
36
 
37
37
  export default function bashCmMergeGuard(pi: ExtensionAPI) {
38
38
  pi.on("tool_call", async (event, ctx) => {
39
- if (!isBashToolCall(event as { toolName?: string })) return;
39
+ if (!isBashToolCall(event)) return;
40
40
 
41
41
  const command = typeof event.input.command === "string" ? event.input.command : "";
42
42
  if (!command || !commandRunsUnsafeCmMerge(command)) return;
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { type TSchema, Type } from "typebox";
4
4
  import * as core from "./src/plastic-core";
5
+ import { renderPlasticCall, renderPlasticResult, renderPlasticSearchResult } from "./src/plastic-renderers";
5
6
  import {
6
7
  getEffectivePlasticToolOwnership,
7
8
  getInitiallyInactivePlasticTools,
@@ -39,8 +40,8 @@ type ToolConfig = {
39
40
 
40
41
  const EXTENSION_SOURCE_PATH = fileURLToPath(import.meta.url);
41
42
  const EMPTY_PARAMETERS = Type.Object({});
42
- const enumSchema = <T extends readonly [string, ...string[]]>(values: T, description: string): TSchema =>
43
- Type.Union(values.map((value) => Type.Literal(value)) as [TSchema, TSchema, ...TSchema[]], { description });
43
+ const enumSchema = (values: readonly string[], description: string): TSchema =>
44
+ Type.Union(values.map((value) => Type.Literal(value)), { description });
44
45
 
45
46
  const OUTPUT_FORMAT_SCHEMA = enumSchema(["text", "json"], "Output format. Defaults to text.");
46
47
  const PENDING_CHANGES_SCHEMA = enumSchema(["shelve", "bring", "cancel"], "How to handle pending changes when switching branches.");
@@ -371,148 +372,11 @@ function toToolName(exportName: string): string {
371
372
  return `plastic_${exportName}`;
372
373
  }
373
374
 
374
- type TextLikeComponent = {
375
- invalidate: () => void;
376
- render: (width: number) => string[];
377
- };
378
-
379
- type RenderTheme = {
380
- fg?: (color: string, text: string) => string;
381
- bold?: (text: string) => string;
382
- };
383
-
384
- type PlasticToolResult = {
385
- content?: Array<{ type?: string; text?: string }>;
386
- details?: {
387
- exportName?: string;
388
- rawResult?: unknown;
389
- };
390
- };
391
-
392
- const COLLAPSED_RESULT_LINES = 12;
393
- const ANSI_PATTERN = /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]/g;
394
-
395
375
  function toText(result: unknown): string {
396
- if (typeof result === "string") {
397
- return result;
398
- }
399
-
376
+ if (typeof result === "string") return result;
400
377
  return JSON.stringify(result, null, 2);
401
378
  }
402
379
 
403
- function visibleLength(value: string): number {
404
- return value.replace(ANSI_PATTERN, "").length;
405
- }
406
-
407
- function truncateAnsiLine(value: string, width: number): string {
408
- if (width <= 0 || !value) {
409
- return "";
410
- }
411
-
412
- if (visibleLength(value) <= width) {
413
- return value;
414
- }
415
-
416
- const target = Math.max(0, width - 1);
417
- let visible = 0;
418
- let output = "";
419
- for (let index = 0; index < value.length;) {
420
- const remaining = value.slice(index);
421
- const ansi = remaining.match(ANSI_PATTERN);
422
- if (ansi && ansi.index === 0) {
423
- output += ansi[0];
424
- index += ansi[0].length;
425
- continue;
426
- }
427
-
428
- if (visible >= target) {
429
- break;
430
- }
431
-
432
- const codePoint = value.codePointAt(index);
433
- if (codePoint === undefined) {
434
- break;
435
- }
436
-
437
- const char = String.fromCodePoint(codePoint);
438
- output += char;
439
- visible += 1;
440
- index += char.length;
441
- }
442
-
443
- return `${output}…`;
444
- }
445
-
446
- function textComponent(text: string): TextLikeComponent {
447
- return {
448
- invalidate() {},
449
- render(width: number) {
450
- if (!text) {
451
- return [];
452
- }
453
- return text.split(/\r?\n/).map((line) => truncateAnsiLine(line, width));
454
- },
455
- };
456
- }
457
-
458
- function themed(theme: RenderTheme, color: string, text: string): string {
459
- return typeof theme.fg === "function" ? theme.fg(color, text) : text;
460
- }
461
-
462
- function bold(theme: RenderTheme, text: string): string {
463
- return typeof theme.bold === "function" ? theme.bold(text) : text;
464
- }
465
-
466
- function extractTextContent(result: PlasticToolResult | undefined): string {
467
- return result?.content
468
- ?.filter((entry) => entry?.type === "text")
469
- .map((entry) => String(entry.text ?? ""))
470
- .join("\n") ?? "";
471
- }
472
-
473
- function trimTrailingEmptyLines(lines: string[]): string[] {
474
- let end = lines.length;
475
- while (end > 0 && lines[end - 1]?.trim() === "") {
476
- end -= 1;
477
- }
478
- return lines.slice(0, end);
479
- }
480
-
481
- function renderPlasticCall(exportName: string, args: Record<string, unknown>, theme: RenderTheme): TextLikeComponent {
482
- const target = args.workdir ?? args.branch ?? args.source ?? args.path ?? args.paths ?? args.target ?? args.shelveset ?? args.id ?? "";
483
- const targetText = Array.isArray(target) ? target.join(", ") : String(target);
484
- const suffix = targetText ? ` ${themed(theme, "accent", targetText)}` : "";
485
- return textComponent(`${themed(theme, "toolTitle", bold(theme, toToolName(exportName)))}${suffix}`);
486
- }
487
-
488
- function renderPlasticResult(
489
- exportName: string,
490
- result: PlasticToolResult | undefined,
491
- options: { expanded?: boolean; isPartial?: boolean } | undefined,
492
- theme: RenderTheme,
493
- ): TextLikeComponent {
494
- if (options?.isPartial) {
495
- return textComponent(themed(theme, "warning", `Running ${toToolName(exportName)}...`));
496
- }
497
-
498
- const output = extractTextContent(result);
499
- const lines = trimTrailingEmptyLines(output.split(/\r?\n/).map((line) => line.replace(/\t/g, " ")));
500
- const maxLines = options?.expanded ? lines.length : COLLAPSED_RESULT_LINES;
501
- const displayLines = lines.slice(0, maxLines);
502
- const remaining = lines.length - displayLines.length;
503
- const rendered = displayLines.map((line) => themed(theme, "toolOutput", line));
504
-
505
- if (remaining > 0) {
506
- rendered.push(themed(theme, "muted", `... (${remaining} more lines, ctrl+o to expand)`));
507
- }
508
-
509
- if (rendered.length === 0) {
510
- rendered.push(themed(theme, "toolOutput", "(no output)"));
511
- }
512
-
513
- return textComponent(rendered.join("\n"));
514
- }
515
-
516
380
  function normalizeArgs(args: unknown): Record<string, unknown> {
517
381
  return args && typeof args === "object" ? { ...(args as Record<string, unknown>) } : {};
518
382
  }
@@ -563,7 +427,7 @@ function isCoreSchemaNode(value: unknown): value is CoreSchemaNode {
563
427
 
564
428
  function applySchemaMetadata(schema: TSchema, node: CoreSchemaNode): TSchema {
565
429
  const metadata = node.metadata ?? {};
566
- const options: Record<string, unknown> = {};
430
+ const options: { description?: string; minimum?: number; maximum?: number } = {};
567
431
 
568
432
  if (metadata.description) options.description = metadata.description;
569
433
  if (metadata.min !== undefined) options.minimum = metadata.min;
@@ -571,15 +435,15 @@ function applySchemaMetadata(schema: TSchema, node: CoreSchemaNode): TSchema {
571
435
 
572
436
  let nextSchema: TSchema = schema;
573
437
  if (Object.keys(options).length > 0) {
574
- if (schema.type === "string") nextSchema = Type.String(options);
575
- else if (schema.type === "number" || schema.type === "integer") nextSchema = metadata.int ? Type.Integer(options) : Type.Number(options);
576
- else if (schema.type === "boolean") nextSchema = Type.Boolean(options);
577
- else if (schema.type === "array") nextSchema = Type.Array((schema as any).items ?? Type.Any(), options);
438
+ if (Type.IsString(schema)) nextSchema = Type.String(options);
439
+ else if (Type.IsNumber(schema) || Type.IsInteger(schema)) nextSchema = metadata.int ? Type.Integer(options) : Type.Number(options);
440
+ else if (Type.IsBoolean(schema)) nextSchema = Type.Boolean(options);
441
+ else if (Type.IsArray(schema)) nextSchema = Type.Array(schema.items, options);
578
442
  else nextSchema = Type.Unsafe({ ...schema, ...options });
579
443
  }
580
444
 
581
- if (metadata.int && nextSchema.type === "number") {
582
- nextSchema = Type.Integer({ description: options.description as string | undefined, minimum: options.minimum as number | undefined, maximum: options.maximum as number | undefined });
445
+ if (metadata.int && Type.IsNumber(nextSchema)) {
446
+ nextSchema = Type.Integer(options);
583
447
  }
584
448
 
585
449
  return metadata.optional ? Type.Optional(nextSchema) : nextSchema;
@@ -617,7 +481,7 @@ function convertCoreSchema(node: unknown): TSchema {
617
481
  case "enum": {
618
482
  const values = Array.isArray(node.values) ? node.values : [];
619
483
  if (values.every((value) => typeof value === "string")) {
620
- const valueSet = values as string[];
484
+ const valueSet = values;
621
485
  if (arraysEqual(valueSet, ["text", "json"])) {
622
486
  schema = OUTPUT_FORMAT_SCHEMA;
623
487
  } else if (arraysEqual(valueSet, ["shelve", "bring", "cancel"])) {
@@ -633,17 +497,17 @@ function convertCoreSchema(node: unknown): TSchema {
633
497
  } else if (arraysEqual(valueSet, ["date", "modifieddate", "status"])) {
634
498
  schema = REVIEW_ORDER_BY_SCHEMA;
635
499
  } else {
636
- schema = enumSchema(valueSet as [string, ...string[]], metadataDescription(node));
500
+ schema = enumSchema(valueSet, metadataDescription(node));
637
501
  }
638
502
  } else {
639
- schema = Type.Union(values.map((value) => literalSchema(value)) as [TSchema, TSchema, ...TSchema[]]);
503
+ schema = Type.Union(values.map((value) => literalSchema(value)));
640
504
  }
641
505
  break;
642
506
  }
643
507
  case "union": {
644
508
  const values = Array.isArray(node.values) ? node.values : [];
645
509
  const converted = values.map((value) => convertCoreSchema(value));
646
- schema = converted.length > 1 ? Type.Union(converted as [TSchema, TSchema, ...TSchema[]]) : (converted[0] ?? Type.Any());
510
+ schema = converted.length > 1 ? Type.Union(converted) : (converted[0] ?? Type.Any());
647
511
  break;
648
512
  }
649
513
  default:
@@ -712,11 +576,11 @@ export default function plasticTools(pi: ExtensionAPI) {
712
576
  description: coreTool.description ?? toToolName(exportName),
713
577
  parameters: buildParameters(coreTool.args),
714
578
  prepareArguments: config.prepareArguments,
715
- renderCall(args, theme) {
716
- return renderPlasticCall(exportName, (args ?? {}) as Record<string, unknown>, theme as RenderTheme);
579
+ renderCall(args, theme, context) {
580
+ return renderPlasticCall(exportName, args ?? {}, theme, context);
717
581
  },
718
- renderResult(result, options, theme) {
719
- return renderPlasticResult(exportName, result as PlasticToolResult | undefined, options, theme as RenderTheme);
582
+ renderResult(result, options, theme, context) {
583
+ return renderPlasticResult(exportName, result, options, theme, context);
720
584
  },
721
585
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
722
586
  const normalizedParams = normalizeArgs(params);
@@ -730,6 +594,7 @@ export default function plasticTools(pi: ExtensionAPI) {
730
594
  details: {
731
595
  exportName,
732
596
  rawResult: result,
597
+ ...(typeof normalizedParams.workdir === "string" ? { workdir: normalizedParams.workdir } : {}),
733
598
  },
734
599
  };
735
600
  },
@@ -749,6 +614,12 @@ export default function plasticTools(pi: ExtensionAPI) {
749
614
  toolNames: Type.Optional(Type.Array(Type.String({ description: "Exact public Plastic tool name." }), { maxItems: 4, description: "Optional exact tool names to enable." })),
750
615
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 4, description: "Maximum matching tools to enable. Defaults to the single best keyword match; exact toolNames requests may enable up to four." })),
751
616
  }),
617
+ renderCall(args, theme, context) {
618
+ return renderPlasticCall("tool_search", args ?? {}, theme, context);
619
+ },
620
+ renderResult(result, options, theme, context) {
621
+ return renderPlasticSearchResult(result, options, theme, context);
622
+ },
752
623
  async execute(_toolCallId, params) {
753
624
  if (isPlasticToolBrowseRequest(params)) {
754
625
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -33,7 +33,10 @@
33
33
  ]
34
34
  },
35
35
  "scripts": {
36
- "test": "tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
36
+ "typecheck": "tsc -p tsconfig.json",
37
+ "pretest": "npm run typecheck",
38
+ "test": "tsx tests/tool-definition.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
39
+ "preview:tools": "tsx tests/plastic-tool-rendering.test.ts --preview",
37
40
  "test:live": "tsx tests/plastic-live-smoke.test.ts",
38
41
  "test:live:branch-create": "tsx tests/plastic-branch-create-live.test.ts",
39
42
  "eval:tool-loading": "tsx evals/tool-loading/run-eval.ts"
@@ -41,12 +44,16 @@
41
44
  "devDependencies": {
42
45
  "@aefree/pi-file-discovery": "^0.1.1",
43
46
  "@earendil-works/pi-coding-agent": "0.83.0",
47
+ "@earendil-works/pi-tui": "0.83.0",
48
+ "@types/node": "22.20.2",
44
49
  "tsx": "4.23.5",
45
- "typebox": "^1.3.8"
50
+ "typebox": "^1.3.8",
51
+ "typescript": "7.0.2"
46
52
  },
47
53
  "peerDependencies": {
48
54
  "@aefree/pi-file-discovery": "^0.1.0",
49
55
  "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-tui": "*",
50
57
  "typebox": "*"
51
58
  },
52
59
  "description": "Pi tools and skills for Plastic SCM and Unity Version Control workflows.",
@@ -66,6 +73,9 @@
66
73
  "@earendil-works/pi-coding-agent": {
67
74
  "optional": true
68
75
  },
76
+ "@earendil-works/pi-tui": {
77
+ "optional": true
78
+ },
69
79
  "typebox": {
70
80
  "optional": true
71
81
  }
@@ -10,6 +10,8 @@ Purpose: PlasticSCM operations for Unity workflows.
10
10
 
11
11
  Never run `cm diff` in Pi. It may launch GUI windows and block the CLI agent.
12
12
 
13
+ `cm cat --file=<path>` writes to that path and can overwrite existing files; `--file` is never an input selector. Never point it at a canonical workspace file during inspection or review, even when piping stdout elsewhere. Use typed diff tools for baseline comparisons. If a typed tool cannot represent a required export, read the [historical export rules](references/changeset-operations.md#historical-export-fallback) first. A changeset number is not a file revision ID; never turn `cs:<number>` into `revid:<number>`.
14
+
13
15
  Never run interactive `cm merge --merge` flows. Use `plastic_merge` (preferred) or explicitly pass `--nointeractiveresolution --mergetype=try` (safe default). Use source/destination policy flags only as explicit overrides.
14
16
 
15
17
  Treat `plastic_merge` success as provisional until `plastic_status` confirms there are no merge-in-progress hints. Pending merge links are expected until the merge result is checked in; merge-in-progress hints are not. If files are manually resolved and validated but checkin is blocked by Plastic merge metadata, use `plastic_finalizeMerge` with an explicit source/destination policy before retrying checkin.
@@ -84,6 +84,20 @@ plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevisio
84
84
 
85
85
  The typed diff tools own historical `cm cat --file` materialization and cleanup; do not create agent-authored temporary files for ordinary diffs. `cm cat --raw` is unsupported for this workflow. Use typed retrieval first; `cm cat --file` is the byte-preserving low-level fallback only when a typed tool cannot represent the case.
86
86
 
87
+ ## Historical Export Fallback
88
+
89
+ Use this fallback only when typed retrieval cannot represent the required case. For an ordinary workspace-base comparison, omit `revision` from `plastic_diffFile`; for a changeset comparison, supply its `revision="cs:<number>"` argument.
90
+
91
+ `cm cat <revspec> --file=<output-file>` writes historical bytes to the output file and can overwrite it. `--file` does not select the source. Piping this command to `Set-Content` or redirecting stdout does not change the destination or protect it: with `--file`, the file contents are written there rather than to stdout.
92
+
93
+ Before a necessary manual export:
94
+
95
+ - Verify the source selector. `revid:<id>` means an actual file revision ID, not a workspace changeset number. Use a verified repository/server-qualified revision ID, or a file-qualified `<path>#cs:<number>` selector for a file at a changeset.
96
+ - Create a new unique temporary directory outside the workspace and select a new absolute output path within it. Confirm that the destination does not exist. Never use a canonical workspace path or an existing baseline as the export destination.
97
+ - Export directly to that temporary destination; do not combine `--file` with a stdout pipeline to capture the content. Check command success and the exported content before comparison. Keep comparison baselines immutable.
98
+
99
+ If an export overwrites a workspace file, stop further exports and edits, record the exact command and destination, and inspect pending status. A clean status after exporting base revisions can mean pending work was erased; it does not prove successful review. Report the incident before attempting recovery, and preserve unrelated user changes.
100
+
87
101
  ## Commit Message Format
88
102
 
89
103
  Use conventional commits from ../../_shared/references/conventional-commits.md.
@@ -33,6 +33,8 @@ plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevisio
33
33
 
34
34
  The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base; an added empty file is reported as `added-empty`, not generic unchanged. Changed, moved, and deleted records use their status revision ID when available. A `--nodata` item cannot supply historical/base bytes: refresh the workspace or use two known file-qualified revisions. `plastic_workspaceDiff` requires selected paths or `allPending=true` and returns per-file unavailable outcomes instead of aborting the batch. Text-diff tools use bounded GNU/POSIX text diff, materialize backend operands to ASCII-safe package paths while preserving logical Unicode output labels, treat valid Unity YAML as text, and report genuine binary content explicitly. Do not construct `cm cat` temporary-file recipes for ordinary review. `cm cat --raw` is unsupported in this workflow; typed retrieval owns cleanup, and `cm cat --file` is the byte-preserving low-level fallback only when needed.
35
35
 
36
+ `cm cat --file` is a filesystem write: its argument is the output destination, never the source file. Pointing it at a working file overwrites pending changes even if stdout is piped to a temporary file. Reviewers must use typed comparisons for ordinary baseline review; a necessary unsupported export must follow the [historical export rules](changeset-operations.md#historical-export-fallback), with a new absolute temporary destination outside the workspace and a verified file revision selector.
37
+
36
38
  ## Metadata Listing (No GUI Diff)
37
39
 
38
40
  Use changeset metadata to understand branch activity:
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { isUtf8 } from "node:buffer";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomBytes } from "node:crypto";
5
- import { tool } from "./pi-tool-compat";
5
+ import { tool } from "./tool-definition";
6
6
  import { promises as fs, realpathSync, statSync } from "node:fs";
7
7
  import { tmpdir } from "os";
8
8
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
@@ -0,0 +1,262 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
4
+
5
+ type Args = Record<string, unknown>;
6
+ type RenderTheme = Pick<Theme, "fg" | "bold">;
7
+ type Context = { args?: unknown; cwd?: string; isError?: boolean; lastComponent?: unknown };
8
+ type Result = { content?: Array<{ type: string; text?: string }>; details?: unknown };
9
+ type Options = { expanded?: boolean; isPartial?: boolean };
10
+ type Tone = "success" | "warning" | "error" | "toolOutput";
11
+ type Summary = { label: string; tone: Tone; notices: string[]; rows: string[] };
12
+ const separator = " \u00b7 ";
13
+
14
+ const titles: Record<string, string> = {
15
+ status: "Status", currentBranch: "Current branch", update: "Update", add: "Add", checkin: "Check in", undo: "Undo",
16
+ resolveDeleteChangeConflict: "Resolve deletion", diff: "Diff (disabled)", patch: "Review patch", diffRevisions: "Revision diff",
17
+ diffFile: "File diff", workspaceDiff: "Workspace diff", branchCreate: "Create branch", switchBranch: "Switch branch",
18
+ merge: "Merge", mergeBranches: "Server merge", mergeToBranch: "Merge and check in", finalizeMerge: "Finalize merge",
19
+ branchList: "Branches", branchExists: "Branch exists", branchDelete: "Delete branch", shelvesetCreate: "Create shelveset",
20
+ shelvesetApply: "Apply shelveset", shelvesetDelete: "Delete shelveset", shelvesetList: "Shelvesets",
21
+ codeReviewCreate: "Create review", codeReviewUpdate: "Update review", codeReviewDelete: "Delete reviews",
22
+ codeReviewFind: "Find reviews", workspaceList: "Workspaces", tool_search: "Find tools",
23
+ };
24
+
25
+ function record(value: unknown): Record<string, unknown> {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
27
+ }
28
+ function scalar(value: unknown): string {
29
+ // Parsed numbers must not turn rounded revision/changeset IDs into plausible evidence.
30
+ return typeof value === "string" ? value : typeof value === "number" && Number.isSafeInteger(value) ? String(value) : "";
31
+ }
32
+ function count(value: unknown): number | undefined {
33
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
34
+ }
35
+ function strings(value: unknown): string[] {
36
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
37
+ }
38
+ function records(value: unknown): Record<string, unknown>[] {
39
+ return Array.isArray(value) ? value.map(record) : [];
40
+ }
41
+ function safe(value: string): string {
42
+ return stripVTControlCharacters(value).replace(/\r\n/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "")
43
+ .replace(/("(?:token|secret|password|api[_-]?key)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[redacted]"')
44
+ .replace(/\b(token|secret|password|api[_-]?key)\s*([:=])\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;)}\]]+)/gi, "$1$2[redacted]");
45
+ }
46
+ function compact(value: string, width = 180): string {
47
+ return truncateToWidth(safe(value).replace(/\s+/g, " ").trim(), width);
48
+ }
49
+ function body(result: Result): string {
50
+ return (result.content ?? []).filter(entry => entry.type === "text").map(entry => entry.text ?? "").join("\n");
51
+ }
52
+ function reuse(context: Context | undefined, text: string): Text {
53
+ const component = context?.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
54
+ component.setText(text);
55
+ return component;
56
+ }
57
+ function list(value: unknown): string {
58
+ const items = strings(value);
59
+ return items.slice(0, 2).map(item => compact(item, 85)).join(", ") + (items.length > 2 ? ` (+${items.length - 2} more)` : "");
60
+ }
61
+ function target(name: string, args: Args): string {
62
+ if (name === "tool_search") return list(args.toolNames) || scalar(args.query) || "Browse capabilities";
63
+ if (name === "diffRevisions") return `${scalar(args.leftRevision) || "..."} -> ${scalar(args.rightRevision) || "..."}`;
64
+ if (name === "mergeBranches" || name === "mergeToBranch") return `${scalar(args.source) || "..."} -> ${scalar(args.target) || "parent branch"}`;
65
+ if (name === "patch" || name === "diff") return [scalar(args.source), scalar(args.destination)].filter(Boolean).join(" -> ");
66
+ if (name === "diffFile") return [scalar(args.path), args.revision ? `vs ${scalar(args.revision)}` : "vs workspace base"].join(" ");
67
+ return scalar(args.branch) || scalar(args.source) || scalar(args.path) || list(args.paths) || scalar(args.shelveset)
68
+ || list(args.ids) || scalar(args.id) || scalar(args.target) || scalar(args.nameLike) || scalar(args.titleLike)
69
+ || (args.allPending === true ? "All pending files" : "");
70
+ }
71
+
72
+ export function renderPlasticCall(name: string, input: unknown, theme: RenderTheme, context?: Context): Text {
73
+ const args = record(input);
74
+ const workdir = scalar(args.workdir) || context?.cwd || "";
75
+ const workspace = name === "mergeBranches" ? "server" : workdir.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
76
+ let text = theme.fg("toolTitle", theme.bold(`Plastic${separator}${titles[name] ?? name}`));
77
+ if (workspace && name !== "tool_search") text += theme.fg("dim", ` ${compact(workspace, 60)}`);
78
+ if (args.preflight === true) text += theme.fg("warning", `${separator}preview`);
79
+ const subtitle = target(name, args);
80
+ if (subtitle) text += `\n${theme.fg("accent", compact(subtitle))}`;
81
+ return reuse(context, text);
82
+ }
83
+
84
+ function payload(result: Result, raw: string): Record<string, unknown> {
85
+ const rawResult = record(result.details).rawResult;
86
+ if (rawResult !== null && typeof rawResult === "object") return record(rawResult);
87
+ // Only decode the package's whole JSON envelope, never a JSON fragment in CLI or diff output.
88
+ const source = typeof rawResult === "string" ? rawResult : raw;
89
+ const json = source.match(/^## [^\r\n]+\r?\n\r?\n```json\r?\n([\s\S]*)\r?\n```\s*$/)?.[1] ?? source;
90
+ try { return record(JSON.parse(json)); }
91
+ catch (error) { if (error instanceof SyntaxError) return {}; throw error; }
92
+ }
93
+
94
+ function pending(summary: Record<string, unknown>): string {
95
+ const total = count(summary.totalPending);
96
+ if (total === undefined) return "Status returned";
97
+ const parts = [total === 0 ? "0 pending records" : `${total} pending`];
98
+ for (const key of ["changed", "added", "moved", "deleted", "private", "other"]) {
99
+ const amount = count(summary[key]);
100
+ if (amount) parts.push(`${amount} ${key}`);
101
+ }
102
+ return parts.join(separator);
103
+ }
104
+
105
+ function summarize(name: string, result: Result, raw: string, context?: Context): Summary {
106
+ const envelope = payload(result, raw);
107
+ const data = record(envelope.data);
108
+ const action = scalar(envelope.action);
109
+ const notices = strings(envelope.warnings);
110
+ const summary: Summary = { label: "Result returned", tone: "toolOutput", notices, rows: [] };
111
+ const textLines = raw.split(/\r?\n/).filter(line => line.trim());
112
+ const preview = record(context?.args).preflight === true || action.endsWith("-preflight") || envelope.outcome === "preflight";
113
+ if (context?.isError) {
114
+ summary.label = "Failed";
115
+ summary.tone = "error";
116
+ summary.rows = textLines.slice(0, 3);
117
+ return summary;
118
+ }
119
+ if (preview) {
120
+ const wouldRun = typeof data.wouldRun === "boolean" ? data.wouldRun : /^- Would run: no$/m.test(raw) ? false : /^- Would run: yes$/m.test(raw) ? true : undefined;
121
+ summary.label = wouldRun === false ? "Preview: would not run" : wouldRun === true ? "Preview: would run" : "Preview returned";
122
+ summary.tone = "warning";
123
+ summary.rows = action ? [scalar(data.reason) || scalar(data.errorCode), scalar(data.strategy)].filter(Boolean) : textLines.filter(line => /^- (?:Reason|Strategy|Command):/.test(line));
124
+ } else if (name === "mergeBranches" && typeof envelope.outcome === "string") {
125
+ summary.label = `Server merge: ${envelope.outcome}`;
126
+ summary.tone = envelope.outcome === "completed" ? "success" : "warning";
127
+ const changeset = scalar(record(data.createdChangeset).id);
128
+ if (changeset) summary.label += `${separator}cs:${changeset}`;
129
+ if (data.mergeLinkIdentity === "unverified" || data.xlinkEffects === "unverified") notices.push("Merge-link identity and Xlink effects remain unverified.");
130
+ if (data.effect === "uncertain" || data.effect === "not-proven") notices.unshift("Effects not proven; inspect server state before any retry.");
131
+ if (data.timedOut === true) notices.unshift("Command timed out; effects uncertain.");
132
+ if (data.aborted === true) notices.unshift("Command cancelled; effects uncertain.");
133
+ } else if (data.checkedIn === false || record(data.switchOutcome).kind === "canceled" || data.kind === "canceled" || data.strategy === "cancel-with-pending") {
134
+ summary.label = data.checkedIn === false ? "Checkin not performed" : "Switch cancelled";
135
+ summary.tone = "warning";
136
+ const reason = scalar(record(data.switchOutcome).reason) || scalar(data.reason);
137
+ if (reason) notices.unshift(reason);
138
+ } else if (envelope.ok === false) {
139
+ summary.label = "Operation not successful";
140
+ summary.tone = "error";
141
+ } else if (name === "status" && count(record(data.summary).totalPending) !== undefined) {
142
+ summary.label = pending(record(data.summary));
143
+ summary.rows = records(data.items).map(item => `${scalar(item.statusCode) || scalar(item.kind)} ${scalar(item.sourcePath) ? `${scalar(item.sourcePath)} -> ` : ""}${scalar(item.path)}`);
144
+ const omitted = count(record(data.itemCount).omitted);
145
+ if (omitted) notices.push(`${omitted} status records omitted by the tool.`);
146
+ } else if (name === "currentBranch" && typeof data.branch === "string") {
147
+ summary.label = data.branch;
148
+ } else if (name === "branchExists" && /^(true|false)$/.test(raw.trim())) {
149
+ summary.label = raw.trim() === "true" ? "Branch found" : "Branch not found";
150
+ } else if ((name === "diffFile" || name === "diffRevisions") && typeof data.status === "string") {
151
+ summary.label = data.status === "unchanged" ? "No differences" : data.status === "binary-different" ? "Binary content differs" : data.status === "added-empty" ? "Added file is empty" : "Text differences";
152
+ if (data.binary === true) notices.push("Binary comparison; no text hunks.");
153
+ } else if (name === "workspaceDiff" && Array.isArray(data.outcomes)) {
154
+ const outcomes = records(data.outcomes);
155
+ const unavailable = outcomes.filter(item => item.status === "unavailable");
156
+ const skipped = outcomes.filter(item => item.status === "skipped-directory");
157
+ summary.label = `${outcomes.length} results${separator}${outcomes.filter(item => item.changed === true).length} changed${separator}${unavailable.length} unavailable`;
158
+ if (unavailable.length) summary.tone = "warning";
159
+ if (skipped.length) notices.push(`${skipped.length} directories skipped.`);
160
+ summary.rows = [...unavailable, ...outcomes.filter(item => item.status !== "unavailable" && item.status !== "skipped-directory"), ...skipped].map(item => `${scalar(item.path)}: ${scalar(item.status)}${item.error ? ` - ${scalar(item.error)}` : ""}`);
161
+ if (outcomes.some(item => item.truncated === true)) notices.push("Some file diffs were truncated by the tool.");
162
+ if (count(data.omittedOutcomes)) notices.push(`${data.omittedOutcomes} outcomes omitted by the tool.`);
163
+ if (count(data.skippedByLimit)) notices.push(`${data.skippedByLimit} pending items skipped by the file limit.`);
164
+ } else if (name === "patch" && typeof envelope.status === "string") {
165
+ summary.label = `Patch ${envelope.status}${count(envelope.bytes) !== undefined ? `${separator}${envelope.bytes} bytes` : ""}`;
166
+ if (envelope.binaryLimited === true) notices.push("Binary content could not be fully represented.");
167
+ if (envelope.truncated === true) notices.push("Patch output truncated by the tool.");
168
+ summary.rows = [scalar(envelope.output)].filter(Boolean);
169
+ } else if (data.checkedIn === true) {
170
+ summary.label = `Merge checked in${scalar(data.finalBranch) ? `${separator}${scalar(data.finalBranch)}` : ""}`;
171
+ summary.tone = "success";
172
+ } else if (count(data.resultCount) !== undefined) {
173
+ summary.label = `${data.resultCount} returned rows`;
174
+ summary.rows = scalar(data.rawOutput).split(/\r?\n/).filter(Boolean);
175
+ } else if (action) {
176
+ summary.label = `${titles[name] ?? action}: result returned`;
177
+ const outputLines = scalar(data.rawOutput).split(/\r?\n/).filter(Boolean);
178
+ if (outputLines.length) { summary.label = outputLines[0]; summary.rows = outputLines.slice(1); }
179
+ if (name === "switchBranch" && typeof data.branchAfter === "string") summary.label = `Workspace branch: ${data.branchAfter}`;
180
+ if (name === "merge") summary.label = "Merge command returned";
181
+ } else {
182
+ // Unknown and legacy text results stay neutral: command completion is not proof of a mutation.
183
+ summary.label = textLines[0]?.replace(/^#+\s*/, "") || "No output";
184
+ summary.rows = textLines.slice(1);
185
+ }
186
+ if (!action) {
187
+ const urgent = textLines.filter(line => /^(?:## .* (?:Blocked|Paused|Uncertain|Unsupported|Conflict)\b|(?:- )?(?:Unavailable:|Error:|Reason:|Effect:|.*[Cc]ancel(?:l)?ed\b))/.test(line));
188
+ const warnings = textLines.filter(line => /^(?:- )?(?:Warning:|Merge-link identity:|Xlink effects:)|^\[.*truncat/.test(line));
189
+ notices.unshift(...urgent.map(line => line.replace(/^#+\s*/, "")));
190
+ notices.push(...warnings);
191
+ }
192
+ if (data.truncated === true || data.outputTruncated === true) notices.push("Output truncated by the tool.");
193
+ const limited = data.truncated === true || data.outputTruncated === true || envelope.truncated === true
194
+ || Boolean(count(record(data.itemCount).omitted) || count(data.omittedOutcomes) || count(data.skippedByLimit))
195
+ || records(data.outcomes).some(item => item.truncated === true);
196
+ if (limited) summary.label += `${separator}partial output`;
197
+ for (const state of [record(data.mergeState), record(data.mergeStateAfterMerge), record(data.mergeStateAfterFinalize)]) {
198
+ if (state.hasMergeInProgress === true) notices.unshift("Merge still in progress; finalization may be required.");
199
+ const links = strings(state.pendingMergeLinks).length;
200
+ if (links) notices.push(`${links} pending merge links.`);
201
+ }
202
+ if (data.usedNoChangesRecovery === true) notices.unshift("Checkin used clean-workspace recovery; inspect original evidence.");
203
+ if (typeof envelope.nextSuggestedAction === "string") notices.push(envelope.nextSuggestedAction);
204
+ if (notices.length && summary.tone !== "error") summary.tone = "warning";
205
+ return summary;
206
+ }
207
+
208
+ function evidence(text: string, theme: RenderTheme): string {
209
+ return safe(text).split("\n").map(line => {
210
+ const color = line.startsWith("+") ? "toolDiffAdded" : line.startsWith("-") ? "toolDiffRemoved" : line.startsWith("@@") ? "accent" : "toolOutput";
211
+ return theme.fg(color, line);
212
+ }).join("\n");
213
+ }
214
+
215
+ export function renderPlasticResult(name: string, result: Result, options: Options, theme: RenderTheme, context?: Context): Text {
216
+ const raw = body(result);
217
+ if (options.isPartial) return reuse(context, theme.fg("warning", `Running${separator}${compact(raw || titles[name] || name)}`));
218
+ const summary = summarize(name, result, raw, context);
219
+ const symbol = summary.tone === "success" ? "\u2713" : summary.tone === "error" ? "\u2717" : summary.tone === "warning" ? "!" : "\u2022";
220
+ let text = theme.fg(summary.tone, `${symbol} ${compact(summary.label, 220)}`);
221
+ const notices = [...new Set(summary.notices)];
222
+ for (const notice of notices.slice(0, options.expanded ? notices.length : 2)) text += `\n${theme.fg("warning", options.expanded ? safe(notice) : compact(notice))}`;
223
+ if (!options.expanded) {
224
+ if (notices.length > 2) text += `\n${theme.fg("dim", `${notices.length - 2} more notices`)}`;
225
+ for (const row of summary.rows.slice(0, 2)) text += `\n${theme.fg(summary.tone === "error" ? "error" : "muted", compact(row))}`;
226
+ if (summary.rows.length > 2) text += `\n${theme.fg("dim", `${summary.rows.length - 2} more rows`)}`;
227
+ if (raw) text += `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}`;
228
+ } else {
229
+ const section = (title: string, value: string) => { if (value) text += `\n\n${theme.fg("toolTitle", theme.bold(title))}\n${value}`; };
230
+ const args = record(context?.args);
231
+ const workdir = scalar(record(result.details).workdir) || scalar(args.workdir) || context?.cwd;
232
+ if (workdir && name !== "mergeBranches" && name !== "tool_search") section("Workspace", theme.fg("muted", safe(workdir)));
233
+ if (Object.keys(args).length) section("Request", theme.fg("muted", safe(JSON.stringify(args, null, 2))));
234
+ const envelope = payload(result, raw);
235
+ const data = record(envelope.data);
236
+ if (typeof data.diff === "string") section("Diff", evidence(data.diff, theme));
237
+ for (const item of records(data.outcomes)) {
238
+ if (typeof item.diff === "string") section(safe(scalar(item.path)), evidence(item.diff, theme));
239
+ }
240
+ if (name === "patch" && typeof envelope.content === "string") section("Patch", evidence(envelope.content, theme));
241
+ // Keep original content, including JSON numeric lexemes and duplicate keys, as the evidence.
242
+ section("Evidence", evidence(raw, theme));
243
+ }
244
+ return reuse(context, text);
245
+ }
246
+
247
+ export function renderPlasticSearchResult(result: Result, options: Options, theme: RenderTheme, context?: Context): Text {
248
+ if (options.isPartial || context?.isError) return renderPlasticResult("tool_search", result, options, theme, context);
249
+ const details = record(result.details);
250
+ const added = strings(details.added);
251
+ const matches = strings(details.matches);
252
+ const unavailable = strings(details.unavailableToolNames);
253
+ if (!Array.isArray(details.matches)) return renderPlasticResult("tool_search", result, options, theme, context);
254
+ const label = details.browse === true ? "Browse Plastic capabilities" : matches.length === 0 ? "No executable tools matched" : `${added.length} activated${separator}${strings(details.alreadyActive).length} already active`;
255
+ const tone = details.browse === true ? "toolOutput" : unavailable.length || matches.length === 0 ? "warning" : "success";
256
+ let text = theme.fg(tone, `${tone === "success" ? "\u2713" : tone === "warning" ? "!" : "\u2022"} ${label}`);
257
+ if (unavailable.length) text += `\n${theme.fg("warning", compact(`Unavailable: ${unavailable.join(", ")}`))}`;
258
+ for (const name of matches) text += `\n${theme.fg("accent", compact(name))}`;
259
+ if (options.expanded) text += `\n\n${theme.fg("toolTitle", theme.bold("Guidance"))}\n${evidence(body(result), theme)}`;
260
+ else text += `\n${theme.fg("dim", keyHint("app.tools.expand", "guidance"))}`;
261
+ return reuse(context, text);
262
+ }
@@ -0,0 +1,71 @@
1
+ export interface SchemaNode<Value = unknown> {
2
+ // Type-only carrier: no runtime field or validation behavior is added.
3
+ readonly valueType?: Value;
4
+ kind: string;
5
+ values?: unknown;
6
+ inner?: unknown;
7
+ metadata?: Record<string, unknown>;
8
+ optional(): SchemaNode<Value> & { readonly optionalField?: true };
9
+ describe(description: string): this;
10
+ min(value: number): this;
11
+ max(value: number): this;
12
+ int(): this;
13
+ }
14
+
15
+ type InferSchema<Node> = Node extends SchemaNode<infer Value> ? Value : Node;
16
+ export type InferArguments<Args extends Record<string, SchemaNode>> = {
17
+ [Key in keyof Args as "optionalField" extends keyof Args[Key] ? never : Key]: InferSchema<Args[Key]>;
18
+ } & {
19
+ [Key in keyof Args as "optionalField" extends keyof Args[Key] ? Key : never]?: InferSchema<Args[Key]>;
20
+ };
21
+
22
+ type ToolDefinition<Args extends Record<string, SchemaNode>, Result> = {
23
+ description: string;
24
+ args: Args;
25
+ execute: (args: InferArguments<Args>) => Result;
26
+ };
27
+
28
+ function createSchemaNode<Value>(kind: string, values?: unknown, inner?: unknown): SchemaNode<Value> {
29
+ const node: SchemaNode<Value> = {
30
+ kind,
31
+ values,
32
+ inner,
33
+ metadata: {},
34
+ optional() {
35
+ this.metadata = { ...(this.metadata ?? {}), optional: true };
36
+ return this;
37
+ },
38
+ describe(description: string) {
39
+ this.metadata = { ...(this.metadata ?? {}), description };
40
+ return this;
41
+ },
42
+ min(value: number) {
43
+ this.metadata = { ...(this.metadata ?? {}), min: value };
44
+ return this;
45
+ },
46
+ max(value: number) {
47
+ this.metadata = { ...(this.metadata ?? {}), max: value };
48
+ return this;
49
+ },
50
+ int() {
51
+ this.metadata = { ...(this.metadata ?? {}), int: true };
52
+ return this;
53
+ },
54
+ };
55
+ return node;
56
+ }
57
+
58
+ export const tool = Object.assign(
59
+ <Args extends Record<string, SchemaNode>, Result>(definition: ToolDefinition<Args, Result>): ToolDefinition<Args, Result> => definition,
60
+ {
61
+ schema: {
62
+ string: () => createSchemaNode<string>("string"),
63
+ number: () => createSchemaNode<number>("number"),
64
+ boolean: () => createSchemaNode<boolean>("boolean"),
65
+ any: () => createSchemaNode<unknown>("any"),
66
+ enum: <const Values extends readonly unknown[]>(values: Values) => createSchemaNode<Values[number]>("enum", [...values]),
67
+ union: <const Values extends readonly unknown[]>(values: Values) => createSchemaNode<InferSchema<Values[number]>>("union", [...values]),
68
+ array: <Inner>(inner: Inner) => createSchemaNode<InferSchema<Inner>[]>("array", undefined, inner),
69
+ },
70
+ },
71
+ );
@@ -1,56 +0,0 @@
1
- type SchemaNode = {
2
- kind: string;
3
- values?: unknown;
4
- inner?: unknown;
5
- metadata?: Record<string, unknown>;
6
- optional(): SchemaNode;
7
- describe(description: string): SchemaNode;
8
- min(value: number): SchemaNode;
9
- max(value: number): SchemaNode;
10
- int(): SchemaNode;
11
- };
12
-
13
- function createSchemaNode(kind: string, values?: unknown, inner?: unknown): SchemaNode {
14
- const node: SchemaNode = {
15
- kind,
16
- values,
17
- inner,
18
- metadata: {},
19
- optional() {
20
- this.metadata = { ...(this.metadata ?? {}), optional: true };
21
- return this;
22
- },
23
- describe(description: string) {
24
- this.metadata = { ...(this.metadata ?? {}), description };
25
- return this;
26
- },
27
- min(value: number) {
28
- this.metadata = { ...(this.metadata ?? {}), min: value };
29
- return this;
30
- },
31
- max(value: number) {
32
- this.metadata = { ...(this.metadata ?? {}), max: value };
33
- return this;
34
- },
35
- int() {
36
- this.metadata = { ...(this.metadata ?? {}), int: true };
37
- return this;
38
- },
39
- };
40
- return node;
41
- }
42
-
43
- export const tool = Object.assign(
44
- <T extends Record<string, unknown>>(definition: T): T => definition,
45
- {
46
- schema: {
47
- string: () => createSchemaNode("string"),
48
- number: () => createSchemaNode("number"),
49
- boolean: () => createSchemaNode("boolean"),
50
- any: () => createSchemaNode("any"),
51
- enum: (values: readonly unknown[]) => createSchemaNode("enum", [...values]),
52
- union: (values: readonly unknown[]) => createSchemaNode("union", [...values]),
53
- array: (inner: unknown) => createSchemaNode("array", undefined, inner),
54
- },
55
- },
56
- );