@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/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
- /** True when a severity tier is high enough to warrant the verify pass. */
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) => Number(f?.severity) >= 5 && Number(f?.confidence) >= 60,
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
- : 4000;
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
- : 120;
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
- : 60_000;
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
- // ── Slash-command argument completions (pi-tui contract) ─────────────
1588
- //
1589
- // `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
1590
- // passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
1591
- // text after the command name and, on accept, `applyCompletion` replaces
1592
- // that whole argument text with `item.value` (the slash-argument branch:
1593
- // `newLine = beforePrefix + item.value`). So an argument-position item's
1594
- // `value` must be the complete replacement — e.g. `"use <name>"`, never the
1595
- // bare token. Pi's own /mcp extension follows this contract
1596
- // (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
1597
- // that enforces it: it keeps every committed token and appends (or replaces
1598
- // the last partial token with) `token`.
1599
-
1600
- /** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
1601
- export interface ArgumentCompletion {
1602
- value: string;
1603
- label: string;
1604
- description?: string;
1605
- }
1606
-
1607
- /**
1608
- * Build the full-argument replacement `value` for a completion token.
1609
- *
1610
- * `prefix` is the typed argument text so far. Tokens separated by whitespace
1611
- * are "committed"; if `prefix` ends with whitespace the user is starting a
1612
- * fresh token, so `token` is appended — otherwise it replaces the trailing
1613
- * partial token. Pure; never throws.
1614
- *
1615
- * joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
1616
- * → "use Syn-Deepseek-v4.1-Flash-Planner"
1617
- * joinArgumentValue("use ", "none") → "use none"
1618
- * joinArgumentValue("", "list") → "list"
1619
- */
1620
- export function joinArgumentValue(prefix: string, token: string): string {
1621
- const raw = String(prefix ?? "");
1622
- const endsWithWs = /\s$/.test(raw);
1623
- const parts = raw.trim().split(/\s+/).filter(Boolean);
1624
- const committed = endsWithWs ? parts : parts.slice(0, -1);
1625
- return [...committed, token].join(" ");
1626
- }
1627
-
1628
- /**
1629
- * Exact-match suppression for slash-command argument completions: when the
1630
- * token being typed (`partial`, "" while the prefix ends with whitespace)
1631
- * already equals the ONLY remaining candidate, return true so the completer
1632
- * yields null — pi's editor then closes the dropdown and the FIRST Enter
1633
- * submits instead of being consumed by the completion-accept handler
1634
- * (editor.js `tui.select.confirm`). Pure.
1635
- */
1636
- export function suppressExactArgumentMatch(
1637
- candidates: readonly string[],
1638
- partial: string,
1639
- ): boolean {
1640
- if (!partial) return false;
1641
- return (
1642
- candidates.length === 1 &&
1643
- candidates[0].toLowerCase() === partial.toLowerCase()
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
+ }