@dieulc/workflow 0.6.1 → 0.8.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/README.md +126 -40
- package/commit-policy.ts +43 -0
- package/edit-policy.ts +193 -14
- package/index.ts +3090 -1148
- package/model-default.ts +535 -0
- package/package.json +10 -3
- package/picker.ts +246 -0
- package/profiles.ts +98 -1
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +372 -69
package/tab-editor.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow editor: gives Tab back to autocomplete while a suggestion list is
|
|
3
|
+
* visible, and lets the existing `tab` extension shortcut cycle modes when it
|
|
4
|
+
* is not.
|
|
5
|
+
*
|
|
6
|
+
* Why a custom editor is needed: Pi's `CustomEditor.handleInput()` dispatches
|
|
7
|
+
* extension shortcuts before any editor/autocomplete handling, and the
|
|
8
|
+
* workflow registers a raw `tab` shortcut. A plugin cannot make that dispatch
|
|
9
|
+
* conditional, so the workflow installs this thin subclass through the public
|
|
10
|
+
* `ctx.ui.setEditorComponent()` API. The subclass only changes the precedence
|
|
11
|
+
* for the completion branch; every other key and every other extension
|
|
12
|
+
* shortcut keeps the normal path.
|
|
13
|
+
*
|
|
14
|
+
* Public-API constraints:
|
|
15
|
+
* - `isShowingAutocomplete()` is public.
|
|
16
|
+
* - The editor's `autocompleteList`, `autocompletePrefix` and
|
|
17
|
+
* `handleTabCompletion()` are private, so the only clean way to accept the
|
|
18
|
+
* highlighted item is to suppress the extension bridge for one synchronous
|
|
19
|
+
* `super.handleInput()` call.
|
|
20
|
+
* - `onExtensionShortcut` is installed by the core *after* this class is
|
|
21
|
+
* constructed. Do not declare a same-named field: an own handler would stop
|
|
22
|
+
* the core from installing its bridge and silently break every extension
|
|
23
|
+
* shortcut.
|
|
24
|
+
*
|
|
25
|
+
* The history helpers keep prompt history alive across the editor swap that
|
|
26
|
+
* `setCustomEditorComponent()` performs: the core copies the editor text but not
|
|
27
|
+
* the `history` array.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
CustomEditor,
|
|
32
|
+
type KeybindingsManager,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import type { Editor, EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
35
|
+
import { decideTabAction } from "./tab-policy.ts";
|
|
36
|
+
|
|
37
|
+
/** Minimal structural shape of a session entry that can carry user text. */
|
|
38
|
+
export interface PromptHistoryEntry {
|
|
39
|
+
type?: string;
|
|
40
|
+
message?: {
|
|
41
|
+
role?: string;
|
|
42
|
+
content?:
|
|
43
|
+
| string
|
|
44
|
+
| ReadonlyArray<{ type?: string; text?: string }>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* User prompt texts from session entries, oldest first.
|
|
50
|
+
*
|
|
51
|
+
* Mirrors pi's `getUserMessageText()`: string content or the concatenation of
|
|
52
|
+
* text blocks. Entries without user text (assistant/tool/system, or an empty
|
|
53
|
+
* message) are skipped.
|
|
54
|
+
*/
|
|
55
|
+
export function promptHistoryTexts(
|
|
56
|
+
entries: readonly PromptHistoryEntry[],
|
|
57
|
+
): string[] {
|
|
58
|
+
const texts: string[] = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (!entry || entry.type !== "message") continue;
|
|
61
|
+
const message = entry.message;
|
|
62
|
+
if (!message || message.role !== "user") continue;
|
|
63
|
+
|
|
64
|
+
const content = message.content;
|
|
65
|
+
let text = "";
|
|
66
|
+
if (typeof content === "string") {
|
|
67
|
+
text = content;
|
|
68
|
+
} else {
|
|
69
|
+
for (const block of content ?? []) {
|
|
70
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
71
|
+
text += block.text;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (text.trim()) texts.push(text);
|
|
76
|
+
}
|
|
77
|
+
return texts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Seed a fresh editor's prompt history from session entries.
|
|
82
|
+
*
|
|
83
|
+
* `Editor.addToHistory()` unshifts, so feeding oldest→newest leaves the newest
|
|
84
|
+
* prompt at the cursor end, exactly like the host's own population path.
|
|
85
|
+
*/
|
|
86
|
+
export function seedPromptHistory(
|
|
87
|
+
editor: Pick<Editor, "addToHistory">,
|
|
88
|
+
entries: readonly PromptHistoryEntry[],
|
|
89
|
+
): void {
|
|
90
|
+
for (const text of promptHistoryTexts(entries)) {
|
|
91
|
+
editor.addToHistory(text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class WorkflowTabEditor extends CustomEditor {
|
|
96
|
+
private readonly tabKeybindings: KeybindingsManager;
|
|
97
|
+
private hostHistoryAdded = false;
|
|
98
|
+
|
|
99
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
|
|
100
|
+
super(tui, theme, keybindings);
|
|
101
|
+
this.tabKeybindings = keybindings;
|
|
102
|
+
// The default 1.4.0 editor renders the working spinner in its top border.
|
|
103
|
+
// `embedWorkingStatus` is absent from the 0.84.4 constructor options type
|
|
104
|
+
// this package compiles against, but the runtime check is field-based, so
|
|
105
|
+
// assigning it after `super` restores the default behaviour and is inert on
|
|
106
|
+
// older versions.
|
|
107
|
+
(this as any).embedWorkingStatus = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True once the host has populated this editor's prompt history. */
|
|
111
|
+
hasHostHistory(): boolean {
|
|
112
|
+
return this.hostHistoryAdded;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override addToHistory(text: string): void {
|
|
116
|
+
if (text.trim()) this.hostHistoryAdded = true;
|
|
117
|
+
super.addToHistory(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override handleInput(data: string): void {
|
|
121
|
+
const action = decideTabAction({
|
|
122
|
+
data,
|
|
123
|
+
keybindings: this.tabKeybindings,
|
|
124
|
+
editor: this,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (action === "complete") {
|
|
128
|
+
// Suppress only the extension-shortcut bridge for this synchronous call.
|
|
129
|
+
// `super.handleInput()` then reaches the editor's Tab branch, which
|
|
130
|
+
// applies the highlighted item. `finally` restores the bridge so the
|
|
131
|
+
// workflow `tab` shortcut and every other extension shortcut keep
|
|
132
|
+
// working for the next key.
|
|
133
|
+
const forwarded = this.onExtensionShortcut;
|
|
134
|
+
this.onExtensionShortcut = undefined;
|
|
135
|
+
try {
|
|
136
|
+
super.handleInput(data);
|
|
137
|
+
} finally {
|
|
138
|
+
this.onExtensionShortcut = forwarded;
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// "cycle" (no visible list) and "ignore" (not Tab / disabled binding) use
|
|
144
|
+
// the normal CustomEditor path: extension shortcuts first, then the editor.
|
|
145
|
+
super.handleInput(data);
|
|
146
|
+
}
|
|
147
|
+
}
|
package/tab-policy.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab precedence between Pi's autocomplete and the workflow mode cycle.
|
|
3
|
+
*
|
|
4
|
+
* Pi's `CustomEditor.handleInput()` checks extension shortcuts *before* the
|
|
5
|
+
* editor, so the workflow's registered `tab` shortcut would otherwise swallow
|
|
6
|
+
* every Tab — including the one that accepts the highlighted command, file or
|
|
7
|
+
* document suggestion. The workflow editor asks this policy first:
|
|
8
|
+
*
|
|
9
|
+
* suggestion list visible -> complete (apply the highlighted item)
|
|
10
|
+
* no suggestion list -> cycle (Plan → Build → Default)
|
|
11
|
+
* another key -> ignore (let CustomEditor/Editor handle it)
|
|
12
|
+
* Tab binding disabled -> ignore (never turn a disabled Tab into accept)
|
|
13
|
+
*
|
|
14
|
+
* The rule is deliberately based on menu visibility only, matching Pi Desktop's
|
|
15
|
+
* `Composer.tsx` `menuKey` logic. A `/command` line whose list has not rendered
|
|
16
|
+
* yet is allowed to cycle; the list wins only while it is actually visible.
|
|
17
|
+
*
|
|
18
|
+
* Pure module (type-only imports) so the precedence is unit-testable without a
|
|
19
|
+
* TUI.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Keybinding } from "@earendil-works/pi-tui";
|
|
23
|
+
|
|
24
|
+
export type TabAction = "ignore" | "complete" | "cycle";
|
|
25
|
+
|
|
26
|
+
export interface TabEditorView {
|
|
27
|
+
/** True while a non-empty autocomplete list is visible. */
|
|
28
|
+
isShowingAutocomplete(): boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TabKeyMatcher {
|
|
32
|
+
matches(data: string, keybinding: Keybinding): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decideTabAction(args: {
|
|
36
|
+
data: string;
|
|
37
|
+
keybindings: TabKeyMatcher;
|
|
38
|
+
editor: TabEditorView;
|
|
39
|
+
}): TabAction {
|
|
40
|
+
if (!args.keybindings.matches(args.data, "tui.input.tab")) return "ignore";
|
|
41
|
+
return args.editor.isShowingAutocomplete() ? "complete" : "cycle";
|
|
42
|
+
}
|
package/utils.ts
CHANGED
|
@@ -931,6 +931,53 @@ export function isPlanWritePath(p: string, cwd: string): boolean {
|
|
|
931
931
|
return norm.includes(".pi/plans/") || norm.startsWith(cwdNorm + "/.pi/plans");
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/** Inputs for deciding whether this run may hand a plan off. */
|
|
935
|
+
export interface PlanHandoffEligibility {
|
|
936
|
+
mode: "plan" | "build" | null;
|
|
937
|
+
/** The run started in Plan mode (not a Build turn that flipped to Plan). */
|
|
938
|
+
runStartedInPlan: boolean;
|
|
939
|
+
/** This run wrote/edited a file under `.pi/plans/`. */
|
|
940
|
+
planWrittenThisRun: boolean;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* A handoff requires all three: the workflow is in Plan mode NOW, the run
|
|
945
|
+
* started in Plan mode, and the run actually wrote a plan. Session-lifetime
|
|
946
|
+
* plan files and old todos never authorize a handoff.
|
|
947
|
+
*/
|
|
948
|
+
export function shouldHandoffPlan(input: PlanHandoffEligibility): boolean {
|
|
949
|
+
return (
|
|
950
|
+
input.mode === "plan" &&
|
|
951
|
+
input.runStartedInPlan === true &&
|
|
952
|
+
input.planWrittenThisRun === true
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Last `write`/`edit` tool call in THIS RUN whose path is under `.pi/plans/`.
|
|
958
|
+
* `messages` must be the `agent_end` run messages (the run's `newMessages`),
|
|
959
|
+
* never the session history — scanning history would resurrect old plans.
|
|
960
|
+
* Returns the path as supplied by the tool call; the caller resolves it.
|
|
961
|
+
*/
|
|
962
|
+
export function findPlanWritePathInMessages(
|
|
963
|
+
messages: readonly unknown[],
|
|
964
|
+
cwd: string,
|
|
965
|
+
): string | undefined {
|
|
966
|
+
if (!Array.isArray(messages) || messages.length === 0) return undefined;
|
|
967
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
968
|
+
const m = messages[i] as any;
|
|
969
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
970
|
+
for (let j = m.content.length - 1; j >= 0; j--) {
|
|
971
|
+
const b = m.content[j];
|
|
972
|
+
if (b?.type !== "toolCall") continue;
|
|
973
|
+
if (b.name !== "write" && b.name !== "edit") continue;
|
|
974
|
+
const p = b.arguments?.path ?? b.arguments?.file_path;
|
|
975
|
+
if (typeof p === "string" && p && isPlanWritePath(p, cwd)) return p;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|
|
980
|
+
|
|
934
981
|
// ── Plan date helpers (UTC) ──────────────────────────────────────
|
|
935
982
|
|
|
936
983
|
/** Return YYYY-MM-DD in UTC for the given date (default: now). Uses toISOString slice, deterministic across locales. */
|
|
@@ -1174,11 +1221,21 @@ export function reviewFindingTier(f: {
|
|
|
1174
1221
|
return "Info";
|
|
1175
1222
|
}
|
|
1176
1223
|
|
|
1177
|
-
/**
|
|
1224
|
+
/**
|
|
1225
|
+
* True when a severity tier is high enough to warrant the verify pass.
|
|
1226
|
+
*
|
|
1227
|
+
* Step 9: only accepted findings at severity >= 7 (with confidence >= 60)
|
|
1228
|
+
* trigger self-verification. Lower findings are not worth a second full LLM
|
|
1229
|
+
* turn; empty, low-severity, or rejected-only findings resubmit pass 1's
|
|
1230
|
+
* plan directly.
|
|
1231
|
+
*/
|
|
1178
1232
|
export function reviewNeedsVerification(findings: ReviewFinding[]): boolean {
|
|
1179
1233
|
if (!Array.isArray(findings)) return false;
|
|
1180
1234
|
return findings.some(
|
|
1181
|
-
(f) =>
|
|
1235
|
+
(f) =>
|
|
1236
|
+
Number(f?.severity) >= 7 &&
|
|
1237
|
+
Number(f?.confidence) >= 60 &&
|
|
1238
|
+
f?.disposition === "accepted",
|
|
1182
1239
|
);
|
|
1183
1240
|
}
|
|
1184
1241
|
|
|
@@ -1285,6 +1342,79 @@ export function renderReviewChangelog(
|
|
|
1285
1342
|
return lines.join("\n").trimEnd();
|
|
1286
1343
|
}
|
|
1287
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* Minimal unified diff between two plan texts (step 9).
|
|
1347
|
+
*
|
|
1348
|
+
* Pass 2 only checks "did the rewrite drop a requirement", so it gets a
|
|
1349
|
+
* diff — not two full plans. Prefix/suffix trim with a few context lines
|
|
1350
|
+
* keeps typical localized rewrites at ~1/10th the tokens; a total rewrite
|
|
1351
|
+
* degrades to one hunk (no worse than today). Capped and never throws.
|
|
1352
|
+
*/
|
|
1353
|
+
export function buildPlanDiff(
|
|
1354
|
+
original: string,
|
|
1355
|
+
revised: string,
|
|
1356
|
+
context = 3,
|
|
1357
|
+
): string {
|
|
1358
|
+
const a = String(original ?? "").split("\n");
|
|
1359
|
+
const b = String(revised ?? "").split("\n");
|
|
1360
|
+
if (a.join("\n") === b.join("\n")) return "(no differences)";
|
|
1361
|
+
const ctx = Number.isFinite(context)
|
|
1362
|
+
? Math.max(0, Math.min(10, Math.trunc(Number(context))))
|
|
1363
|
+
: 3;
|
|
1364
|
+
let start = 0;
|
|
1365
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
1366
|
+
start++;
|
|
1367
|
+
let endA = a.length - 1;
|
|
1368
|
+
let endB = b.length - 1;
|
|
1369
|
+
while (endA >= start && endB >= start && a[endA] === b[endB]) {
|
|
1370
|
+
endA--;
|
|
1371
|
+
endB--;
|
|
1372
|
+
}
|
|
1373
|
+
const fromA = Math.max(0, start - ctx);
|
|
1374
|
+
const toA = Math.min(a.length - 1, endA + ctx);
|
|
1375
|
+
const fromB = Math.max(0, start - ctx);
|
|
1376
|
+
const toB = Math.min(b.length - 1, endB + ctx);
|
|
1377
|
+
const out = [
|
|
1378
|
+
"--- original (author)",
|
|
1379
|
+
"+++ rewritten (reviewer)",
|
|
1380
|
+
`@@ -${fromA + 1},${toA - fromA + 1} +${fromB + 1},${toB - fromB + 1} @@`,
|
|
1381
|
+
];
|
|
1382
|
+
for (let i = fromA; i < start; i++) out.push(` ${a[i]}`);
|
|
1383
|
+
for (let i = start; i <= endA; i++) out.push(`- ${a[i]}`);
|
|
1384
|
+
for (let i = start; i <= endB; i++) out.push(`+ ${b[i]}`);
|
|
1385
|
+
const tailFrom = Math.max(endA + 1, endB + 1, start);
|
|
1386
|
+
const tailTo = Math.max(toA, toB);
|
|
1387
|
+
const tailSrc = toA - fromA >= toB - fromB ? a : b;
|
|
1388
|
+
for (let i = tailFrom; i <= Math.min(tailTo, tailSrc.length - 1); i++)
|
|
1389
|
+
out.push(` ${tailSrc[i]}`);
|
|
1390
|
+
const text = out.join("\n");
|
|
1391
|
+
const MAX_DIFF_CHARS = 12_000;
|
|
1392
|
+
return text.length > MAX_DIFF_CHARS
|
|
1393
|
+
? `${text.slice(0, MAX_DIFF_CHARS)}\n… [diff truncated — the rewrite is large; re-read the plan file]`
|
|
1394
|
+
: text;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** Outcome of landing a detached background review (steps 5/11). */
|
|
1398
|
+
export type ReviewLanding = "updated" | "unchanged" | "stale";
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Decide what a landed background review may announce (step 11 contract).
|
|
1402
|
+
* Pure: `diskHash` is the plan file on disk, `inputHash` the reviewed
|
|
1403
|
+
* revision, `finalHash` the reviewer's rewrite. Only `updated` (the disk
|
|
1404
|
+
* holds the rewrite) announces a change; every other outcome keeps the
|
|
1405
|
+
* author's/user's text and stays quiet or defers to a re-run.
|
|
1406
|
+
*/
|
|
1407
|
+
export function classifyReviewLanding(input: {
|
|
1408
|
+
diskHash?: string;
|
|
1409
|
+
inputHash: string;
|
|
1410
|
+
finalHash: string;
|
|
1411
|
+
}): ReviewLanding {
|
|
1412
|
+
if (input.diskHash === undefined) return "unchanged";
|
|
1413
|
+
if (input.diskHash === input.finalHash) return "updated";
|
|
1414
|
+
if (input.diskHash === input.inputHash) return "unchanged";
|
|
1415
|
+
return "stale";
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1288
1418
|
/**
|
|
1289
1419
|
* Strip any previously appended Review Mode appendix from a plan.
|
|
1290
1420
|
* Returns the plan body exactly as the author wrote it.
|
|
@@ -1436,11 +1566,11 @@ export function pruneContextEntriesForReview(
|
|
|
1436
1566
|
opts: PruneReviewContextOptions = {},
|
|
1437
1567
|
): any[] {
|
|
1438
1568
|
const maxChars = Number.isFinite(opts.maxToolResultChars)
|
|
1439
|
-
? Math.max(200, Number(opts.maxToolResultChars))
|
|
1440
|
-
:
|
|
1569
|
+
? Math.min(4000, Math.max(200, Number(opts.maxToolResultChars)))
|
|
1570
|
+
: 1000;
|
|
1441
1571
|
const maxEntries = Number.isFinite(opts.maxEntries)
|
|
1442
|
-
? Math.max(1, Number(opts.maxEntries))
|
|
1443
|
-
:
|
|
1572
|
+
? Math.min(120, Math.max(1, Number(opts.maxEntries)))
|
|
1573
|
+
: 30;
|
|
1444
1574
|
if (!Array.isArray(entries)) return [];
|
|
1445
1575
|
const kept: any[] = [];
|
|
1446
1576
|
try {
|
|
@@ -1554,8 +1684,8 @@ export function renderReviewContextBlock(
|
|
|
1554
1684
|
opts: { maxChars?: number } = {},
|
|
1555
1685
|
): string {
|
|
1556
1686
|
const maxChars = Number.isFinite(opts.maxChars)
|
|
1557
|
-
? Math.max(1000, Number(opts.maxChars))
|
|
1558
|
-
:
|
|
1687
|
+
? Math.min(60_000, Math.max(1000, Number(opts.maxChars)))
|
|
1688
|
+
: 15_000;
|
|
1559
1689
|
if (!Array.isArray(entries) || entries.length === 0) return "";
|
|
1560
1690
|
const lines: string[] = [];
|
|
1561
1691
|
try {
|
|
@@ -1579,67 +1709,240 @@ export function renderReviewContextBlock(
|
|
|
1579
1709
|
}
|
|
1580
1710
|
let out = lines.join("\n\n");
|
|
1581
1711
|
if (out.length > maxChars) {
|
|
1582
|
-
out = `${out.slice(0, maxChars)}\n\n… [context truncated at ${maxChars} chars]`;
|
|
1712
|
+
out = `${out.slice(0, maxChars)}\n\n… [context truncated at ${maxChars} chars — re-read anything you rely on with read/grep]`;
|
|
1583
1713
|
}
|
|
1584
1714
|
return out;
|
|
1585
1715
|
}
|
|
1586
|
-
|
|
1587
|
-
// ──
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
/**
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
*
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
const
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
}
|
|
1716
|
+
|
|
1717
|
+
// ── Planner-context manifest for the reviewer (step 8) ─────────────
|
|
1718
|
+
|
|
1719
|
+
/** What `buildPlannerContextManifest` found in the parent branch. */
|
|
1720
|
+
export interface PlannerContextManifest {
|
|
1721
|
+
/** Markdown section for the pass-1 prompt ("" when there is nothing). */
|
|
1722
|
+
markdown: string;
|
|
1723
|
+
/** Distinct file paths inventoried. */
|
|
1724
|
+
files: number;
|
|
1725
|
+
/** Explore recon summaries included. */
|
|
1726
|
+
reconResults: number;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
export interface BuildPlannerManifestOptions {
|
|
1730
|
+
/** Max files listed in the inventory. */
|
|
1731
|
+
maxFiles?: number;
|
|
1732
|
+
/** Max explore results summarized. */
|
|
1733
|
+
maxReconResults?: number;
|
|
1734
|
+
/** Max chars taken from the tail of each explore result. */
|
|
1735
|
+
maxReconChars?: number;
|
|
1736
|
+
/**
|
|
1737
|
+
* Describe a referenced path (size/mtime/hash token). Return undefined
|
|
1738
|
+
* for paths that cannot be described — the path is still listed.
|
|
1739
|
+
*/
|
|
1740
|
+
describeFile?: (path: string) => string | undefined;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const PLANNER_MANIFEST_MAX_FILES = 200;
|
|
1744
|
+
const PLANNER_MANIFEST_MAX_RECON = 3;
|
|
1745
|
+
const PLANNER_MANIFEST_RECON_CHARS = 700;
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Ordered unique `path`/`file_path` arguments from tool calls in the branch.
|
|
1749
|
+
* These are the files Plan Mode touched — the reviewer can trust them
|
|
1750
|
+
* unless a finding cites them (then it re-reads). Never throws.
|
|
1751
|
+
*/
|
|
1752
|
+
function manifestToolPaths(entries: unknown, maxFiles: number): string[] {
|
|
1753
|
+
const paths: string[] = [];
|
|
1754
|
+
const seen = new Set<string>();
|
|
1755
|
+
const take = (raw: unknown): void => {
|
|
1756
|
+
if (paths.length >= maxFiles) return;
|
|
1757
|
+
if (typeof raw !== "string") return;
|
|
1758
|
+
const p = raw.trim();
|
|
1759
|
+
if (!p || p.length > 500 || seen.has(p)) return;
|
|
1760
|
+
seen.add(p);
|
|
1761
|
+
paths.push(p);
|
|
1762
|
+
};
|
|
1763
|
+
try {
|
|
1764
|
+
if (!Array.isArray(entries)) return paths;
|
|
1765
|
+
for (const e of entries) {
|
|
1766
|
+
if (paths.length >= maxFiles) break;
|
|
1767
|
+
if (!e || typeof e !== "object") continue;
|
|
1768
|
+
const blocks = (e as { message?: { content?: unknown } }).message
|
|
1769
|
+
?.content;
|
|
1770
|
+
if (!Array.isArray(blocks)) continue;
|
|
1771
|
+
for (const b of blocks) {
|
|
1772
|
+
if (!b || typeof b !== "object") continue;
|
|
1773
|
+
const t = (b as { type?: unknown }).type;
|
|
1774
|
+
if (t !== "toolCall" && t !== "tool_use") continue;
|
|
1775
|
+
const args = (b as { arguments?: unknown; input?: unknown })
|
|
1776
|
+
.arguments ?? (b as { input?: unknown }).input;
|
|
1777
|
+
if (args && typeof args === "object") {
|
|
1778
|
+
take((args as Record<string, unknown>).path);
|
|
1779
|
+
take((args as Record<string, unknown>).file_path);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
} catch {
|
|
1784
|
+
/* keep what was collected */
|
|
1785
|
+
}
|
|
1786
|
+
return paths;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Tails of the most recent `explore` tool results (Plan Mode recon), newest
|
|
1791
|
+
* first. Conclusions tend to live at the end, so the tail is kept. Never throws.
|
|
1792
|
+
*/
|
|
1793
|
+
function manifestReconTails(
|
|
1794
|
+
entries: unknown,
|
|
1795
|
+
maxResults: number,
|
|
1796
|
+
maxChars: number,
|
|
1797
|
+
): string[] {
|
|
1798
|
+
const tails: string[] = [];
|
|
1799
|
+
try {
|
|
1800
|
+
if (!Array.isArray(entries)) return tails;
|
|
1801
|
+
for (
|
|
1802
|
+
let i = entries.length - 1;
|
|
1803
|
+
i >= 0 && tails.length < maxResults;
|
|
1804
|
+
i--
|
|
1805
|
+
) {
|
|
1806
|
+
const msg = (entries[i] as { message?: unknown })?.message as
|
|
1807
|
+
| { role?: unknown; toolName?: unknown; content?: unknown }
|
|
1808
|
+
| undefined;
|
|
1809
|
+
if (!msg || msg.role !== "toolResult" || msg.toolName !== "explore")
|
|
1810
|
+
continue;
|
|
1811
|
+
const text = contentBlocksToText(msg.content).trim();
|
|
1812
|
+
if (!text) continue;
|
|
1813
|
+
tails.push(
|
|
1814
|
+
text.length > maxChars ? `…${text.slice(text.length - maxChars)}` : text,
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1817
|
+
} catch {
|
|
1818
|
+
/* keep what was collected */
|
|
1819
|
+
}
|
|
1820
|
+
return tails;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Build the Planner-context manifest: a file inventory with change tokens
|
|
1825
|
+
* plus recent explore recon, so the reviewer verifies instead of re-exploring
|
|
1826
|
+
* (plan 2026-10-23, step 8). Takes the RAW branch — pruned entries elide the
|
|
1827
|
+
* explore tails this needs. Pure except for the optional `describeFile`.
|
|
1828
|
+
* Never throws; empty input yields an empty markdown section.
|
|
1829
|
+
*/
|
|
1830
|
+
export function buildPlannerContextManifest(
|
|
1831
|
+
entries: unknown,
|
|
1832
|
+
opts: BuildPlannerManifestOptions = {},
|
|
1833
|
+
): PlannerContextManifest {
|
|
1834
|
+
const empty: PlannerContextManifest = {
|
|
1835
|
+
markdown: "",
|
|
1836
|
+
files: 0,
|
|
1837
|
+
reconResults: 0,
|
|
1838
|
+
};
|
|
1839
|
+
try {
|
|
1840
|
+
const maxFiles = Number.isFinite(opts.maxFiles)
|
|
1841
|
+
? Math.max(1, Math.min(500, Number(opts.maxFiles)))
|
|
1842
|
+
: PLANNER_MANIFEST_MAX_FILES;
|
|
1843
|
+
const maxRecon = Number.isFinite(opts.maxReconResults)
|
|
1844
|
+
? Math.max(0, Math.min(10, Number(opts.maxReconResults)))
|
|
1845
|
+
: PLANNER_MANIFEST_MAX_RECON;
|
|
1846
|
+
const maxReconChars = Number.isFinite(opts.maxReconChars)
|
|
1847
|
+
? Math.max(100, Math.min(8000, Number(opts.maxReconChars)))
|
|
1848
|
+
: PLANNER_MANIFEST_RECON_CHARS;
|
|
1849
|
+
const paths = manifestToolPaths(entries, maxFiles);
|
|
1850
|
+
const recon = manifestReconTails(entries, maxRecon, maxReconChars);
|
|
1851
|
+
if (paths.length === 0 && recon.length === 0) return empty;
|
|
1852
|
+
const lines = [
|
|
1853
|
+
"## Shared context (Planner — trusted unless a finding cites it)",
|
|
1854
|
+
"",
|
|
1855
|
+
"Plan Mode already explored this repository. Trust this inventory and",
|
|
1856
|
+
"these recon summaries for everything they cover; re-read only files a",
|
|
1857
|
+
"finding anchors, plus one or two spot checks.",
|
|
1858
|
+
"",
|
|
1859
|
+
];
|
|
1860
|
+
if (paths.length > 0) {
|
|
1861
|
+
lines.push(`Files Plan Mode touched (${paths.length}):`, "");
|
|
1862
|
+
for (const p of paths) {
|
|
1863
|
+
let detail = "";
|
|
1864
|
+
try {
|
|
1865
|
+
const d = opts.describeFile?.(p);
|
|
1866
|
+
if (d) detail = ` — ${d}`;
|
|
1867
|
+
} catch {
|
|
1868
|
+
/* a path is still listed without its token */
|
|
1869
|
+
}
|
|
1870
|
+
lines.push(`- \`${p}\`${detail}`);
|
|
1871
|
+
}
|
|
1872
|
+
lines.push("");
|
|
1873
|
+
}
|
|
1874
|
+
if (recon.length > 0) {
|
|
1875
|
+
lines.push(`Planner recon (newest first, ${recon.length}):`, "");
|
|
1876
|
+
recon.forEach((tail, i) => {
|
|
1877
|
+
lines.push(`### Recon ${i + 1}`, "", tail, "");
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
return {
|
|
1881
|
+
markdown: lines.join("\n").trimEnd(),
|
|
1882
|
+
files: paths.length,
|
|
1883
|
+
reconResults: recon.length,
|
|
1884
|
+
};
|
|
1885
|
+
} catch {
|
|
1886
|
+
return empty;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// ── Slash-command argument completions (pi-tui contract) ─────────────
|
|
1891
|
+
//
|
|
1892
|
+
// `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
|
|
1893
|
+
// passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
|
|
1894
|
+
// text after the command name and, on accept, `applyCompletion` replaces
|
|
1895
|
+
// that whole argument text with `item.value` (the slash-argument branch:
|
|
1896
|
+
// `newLine = beforePrefix + item.value`). So an argument-position item's
|
|
1897
|
+
// `value` must be the complete replacement — e.g. `"use <name>"`, never the
|
|
1898
|
+
// bare token. Pi's own /mcp extension follows this contract
|
|
1899
|
+
// (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
|
|
1900
|
+
// that enforces it: it keeps every committed token and appends (or replaces
|
|
1901
|
+
// the last partial token with) `token`.
|
|
1902
|
+
|
|
1903
|
+
/** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
|
|
1904
|
+
export interface ArgumentCompletion {
|
|
1905
|
+
value: string;
|
|
1906
|
+
label: string;
|
|
1907
|
+
description?: string;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* Build the full-argument replacement `value` for a completion token.
|
|
1912
|
+
*
|
|
1913
|
+
* `prefix` is the typed argument text so far. Tokens separated by whitespace
|
|
1914
|
+
* are "committed"; if `prefix` ends with whitespace the user is starting a
|
|
1915
|
+
* fresh token, so `token` is appended — otherwise it replaces the trailing
|
|
1916
|
+
* partial token. Pure; never throws.
|
|
1917
|
+
*
|
|
1918
|
+
* joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
|
|
1919
|
+
* → "use Syn-Deepseek-v4.1-Flash-Planner"
|
|
1920
|
+
* joinArgumentValue("use ", "none") → "use none"
|
|
1921
|
+
* joinArgumentValue("", "list") → "list"
|
|
1922
|
+
*/
|
|
1923
|
+
export function joinArgumentValue(prefix: string, token: string): string {
|
|
1924
|
+
const raw = String(prefix ?? "");
|
|
1925
|
+
const endsWithWs = /\s$/.test(raw);
|
|
1926
|
+
const parts = raw.trim().split(/\s+/).filter(Boolean);
|
|
1927
|
+
const committed = endsWithWs ? parts : parts.slice(0, -1);
|
|
1928
|
+
return [...committed, token].join(" ");
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* Exact-match suppression for slash-command argument completions: when the
|
|
1933
|
+
* token being typed (`partial`, "" while the prefix ends with whitespace)
|
|
1934
|
+
* already equals the ONLY remaining candidate, return true so the completer
|
|
1935
|
+
* yields null — pi's editor then closes the dropdown and the FIRST Enter
|
|
1936
|
+
* submits instead of being consumed by the completion-accept handler
|
|
1937
|
+
* (editor.js `tui.select.confirm`). Pure.
|
|
1938
|
+
*/
|
|
1939
|
+
export function suppressExactArgumentMatch(
|
|
1940
|
+
candidates: readonly string[],
|
|
1941
|
+
partial: string,
|
|
1942
|
+
): boolean {
|
|
1943
|
+
if (!partial) return false;
|
|
1944
|
+
return (
|
|
1945
|
+
candidates.length === 1 &&
|
|
1946
|
+
candidates[0].toLowerCase() === partial.toLowerCase()
|
|
1947
|
+
);
|
|
1948
|
+
}
|