@aefree/pi-plastic 0.6.1 → 0.7.1
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 +22 -0
- package/README.md +15 -4
- package/extensions/bash-cm-diff-guard.ts +1 -1
- package/extensions/bash-cm-merge-guard.ts +1 -1
- package/index.ts +26 -155
- package/package.json +13 -3
- package/skills/_shared/references/conventional-commits.md +0 -33
- package/src/plastic-core.ts +3 -3
- package/src/plastic-renderers.ts +262 -0
- package/src/tool-definition.ts +71 -0
- package/src/pi-tool-compat.ts +0 -56
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Remove co-author attribution instructions and examples from conventional commit guidance.
|
|
15
|
+
- Remove the automatic co-author footer from default merge check-in messages.
|
|
16
|
+
|
|
17
|
+
## [0.7.0] - 2026-09-16
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- Use TypeBox schema guards and array-based unions in the adapter, and narrow Bash tool events directly in both safety guards.
|
|
23
|
+
- Add a pinned strict typecheck before the test suite, covering production sources, extensions, and compile-only argument-inference regressions.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Present Plastic tools and capability search with compact action headers, operation targets, and structured result summaries in Pi's existing tool boxes.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- Add an offline tool preview and behavioral renderer tests covering outcomes, exact evidence, redaction, keybindings, registration, and narrow terminals.
|
|
31
|
+
|
|
10
32
|
## [0.6.1] - 2026-09-14
|
|
11
33
|
|
|
12
34
|
### Fixed
|
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
|
|
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.
|
|
122
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.7.1
|
|
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.
|
|
128
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.7.1
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
43
|
-
Type.Union(values.map((value) => Type.Literal(value))
|
|
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:
|
|
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
|
|
575
|
-
else if (schema
|
|
576
|
-
else if (schema
|
|
577
|
-
else if (schema
|
|
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
|
|
582
|
-
nextSchema = Type.Integer(
|
|
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
|
|
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
|
|
500
|
+
schema = enumSchema(valueSet, metadataDescription(node));
|
|
637
501
|
}
|
|
638
502
|
} else {
|
|
639
|
-
schema = Type.Union(values.map((value) => literalSchema(value))
|
|
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
|
|
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,
|
|
579
|
+
renderCall(args, theme, context) {
|
|
580
|
+
return renderPlasticCall(exportName, args ?? {}, theme, context);
|
|
717
581
|
},
|
|
718
|
-
renderResult(result, options, theme) {
|
|
719
|
-
return renderPlasticResult(exportName, result
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
-
"
|
|
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
|
}
|
|
@@ -112,27 +112,6 @@ Closes #124
|
|
|
112
112
|
Related to #125
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
**Attribution:**
|
|
116
|
-
```
|
|
117
|
-
feat(dashboard): add analytics widget
|
|
118
|
-
|
|
119
|
-
Co-Authored-By: <AI Assistant Name (e.g., Codex, Claude, Pi Coding Agent)>
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## Attribution Footer
|
|
125
|
-
|
|
126
|
-
Add a simple attribution line in the footer when needed:
|
|
127
|
-
|
|
128
|
-
```
|
|
129
|
-
feat(feature): implement new capability
|
|
130
|
-
|
|
131
|
-
[Body explaining the change]
|
|
132
|
-
|
|
133
|
-
Co-Authored-By: <AI Assistant Name (e.g., Codex, Claude, Pi Coding Agent)>
|
|
134
|
-
```
|
|
135
|
-
|
|
136
115
|
---
|
|
137
116
|
|
|
138
117
|
## Examples
|
|
@@ -168,18 +147,6 @@ Changed from O(n²) all-pairs check to spatial hashing.
|
|
|
168
147
|
Reduces frame time by ~15ms with 100+ objects.
|
|
169
148
|
```
|
|
170
149
|
|
|
171
|
-
### Attribution Example
|
|
172
|
-
```
|
|
173
|
-
feat(multiplayer): add lobby system
|
|
174
|
-
|
|
175
|
-
Implements matchmaking lobby with room creation, joining, and ready-up.
|
|
176
|
-
Uses WebSocket for real-time updates.
|
|
177
|
-
|
|
178
|
-
Closes #89
|
|
179
|
-
|
|
180
|
-
Co-Authored-By: <AI Assistant Name (e.g., Codex, Claude, Pi Coding Agent)>
|
|
181
|
-
```
|
|
182
|
-
|
|
183
150
|
---
|
|
184
151
|
|
|
185
152
|
## Usage in Skills
|
package/src/plastic-core.ts
CHANGED
|
@@ -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 "./
|
|
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";
|
|
@@ -4910,10 +4910,10 @@ export const mergeToBranch = tool({
|
|
|
4910
4910
|
const strategy: MergeConflictStrategy = args.strategy ?? "auto";
|
|
4911
4911
|
const updateTarget = args.updateTarget ?? true;
|
|
4912
4912
|
const includePrivate = args.includePrivate ?? false;
|
|
4913
|
-
const cardLine = args.cardRef?.trim() ?
|
|
4913
|
+
const cardLine = args.cardRef?.trim() ? `\n\n${args.cardRef.trim()}` : "";
|
|
4914
4914
|
const checkinMessage = args.message?.trim()
|
|
4915
4915
|
? args.message.trim()
|
|
4916
|
-
: `Merge ${sourceBranch} into ${targetBranch}
|
|
4916
|
+
: `Merge ${sourceBranch} into ${targetBranch}${cardLine}`;
|
|
4917
4917
|
|
|
4918
4918
|
if (isSameBranchSpec(sourceBranch, targetBranch))
|
|
4919
4919
|
{
|
|
@@ -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
|
+
);
|
package/src/pi-tool-compat.ts
DELETED
|
@@ -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
|
-
);
|