@dieulc/workflow 0.7.0 → 0.8.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/utils.ts CHANGED
@@ -931,6 +931,125 @@ 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
+ /** This run wrote/edited a file under `.pi/plans/`. */
938
+ planWrittenThisRun: boolean;
939
+ /**
940
+ * The write happened while Plan mode was active. A run that starts in
941
+ * Default/Build and switches to Plan mid-run counts; a plan written before
942
+ * the switch does not. `agent_end`'s current-run message scan sets this too
943
+ * when the run ends in Plan (the tool_result handler was missed).
944
+ */
945
+ planWrittenInPlanMode: boolean;
946
+ }
947
+
948
+ /**
949
+ * A handoff requires all three: the workflow is in Plan mode NOW, the run
950
+ * wrote a plan, and that write happened under Plan mode. Session-lifetime
951
+ * plan files and old todos never authorize a handoff.
952
+ */
953
+ export function shouldHandoffPlan(input: PlanHandoffEligibility): boolean {
954
+ return (
955
+ input.mode === "plan" &&
956
+ input.planWrittenThisRun === true &&
957
+ input.planWrittenInPlanMode === true
958
+ );
959
+ }
960
+
961
+ /**
962
+ * Last `write`/`edit` tool call in THIS RUN whose path is under `.pi/plans/`.
963
+ * `messages` must be the `agent_end` run messages (the run's `newMessages`),
964
+ * never the session history — scanning history would resurrect old plans.
965
+ * Returns the path as supplied by the tool call; the caller resolves it.
966
+ */
967
+ export function findPlanWritePathInMessages(
968
+ messages: readonly unknown[],
969
+ cwd: string,
970
+ ): string | undefined {
971
+ if (!Array.isArray(messages) || messages.length === 0) return undefined;
972
+ for (let i = messages.length - 1; i >= 0; i--) {
973
+ const m = messages[i] as any;
974
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
975
+ for (let j = m.content.length - 1; j >= 0; j--) {
976
+ const b = m.content[j];
977
+ if (b?.type !== "toolCall") continue;
978
+ if (b.name !== "write" && b.name !== "edit") continue;
979
+ const p = b.arguments?.path ?? b.arguments?.file_path;
980
+ if (typeof p === "string" && p && isPlanWritePath(p, cwd)) return p;
981
+ }
982
+ }
983
+ return undefined;
984
+ }
985
+
986
+ /** Where a manual `/review` found the plan it is about to audit. */
987
+ export type PlanPathSource =
988
+ | "explicit"
989
+ | "run"
990
+ | "session"
991
+ | "handoff"
992
+ | "branch";
993
+
994
+ export interface PlanPathCandidate {
995
+ path?: string | null;
996
+ source: PlanPathSource;
997
+ }
998
+
999
+ export interface PlanPathSelection {
1000
+ path: string;
1001
+ source: PlanPathSource;
1002
+ }
1003
+
1004
+ /**
1005
+ * Pick the first readable plan for a manual `/review`.
1006
+ *
1007
+ * Candidates arrive in priority order (`explicit` → `run` → `session` →
1008
+ * `handoff` → `branch`). Missing, empty and unreadable candidates are skipped;
1009
+ * a plan that disappeared since it was written must never win over the next
1010
+ * candidate. Pure: the caller owns path resolution and the filesystem probe.
1011
+ */
1012
+ export function choosePlanPath(
1013
+ candidates: readonly PlanPathCandidate[],
1014
+ isReadable: (absolutePath: string) => boolean,
1015
+ ): PlanPathSelection | undefined {
1016
+ for (const candidate of candidates) {
1017
+ const p = candidate.path;
1018
+ if (typeof p !== "string" || p.length === 0) continue;
1019
+ if (isReadable(p)) return { path: p, source: candidate.source };
1020
+ }
1021
+ return undefined;
1022
+ }
1023
+
1024
+ /**
1025
+ * Newest plan reference in a session branch, used as the last manual
1026
+ * `/review` fallback after a reload or when the in-memory path was lost.
1027
+ *
1028
+ * Recognises the persisted `workflow` entry (`planFile`), the
1029
+ * `workflow-handoff-resolve` entry (`planPath`), and an assistant `write`/
1030
+ * `edit` tool call under `.pi/plans/`. Scans newest-first and never throws.
1031
+ */
1032
+ export function findLatestPlanWriteInBranch(
1033
+ entries: readonly unknown[],
1034
+ cwd: string,
1035
+ ): string | undefined {
1036
+ if (!Array.isArray(entries) || entries.length === 0) return undefined;
1037
+ for (let i = entries.length - 1; i >= 0; i--) {
1038
+ const e = entries[i] as any;
1039
+ if (!e || typeof e !== "object") continue;
1040
+ if (e.type === "custom") {
1041
+ const p = e.data?.planPath ?? e.data?.planFile;
1042
+ if (typeof p === "string" && p && isPlanWritePath(p, cwd)) return p;
1043
+ continue;
1044
+ }
1045
+ if (e.type === "message" && e.message) {
1046
+ const found = findPlanWritePathInMessages([e.message], cwd);
1047
+ if (found) return found;
1048
+ }
1049
+ }
1050
+ return undefined;
1051
+ }
1052
+
934
1053
  // ── Plan date helpers (UTC) ──────────────────────────────────────
935
1054
 
936
1055
  /** Return YYYY-MM-DD in UTC for the given date (default: now). Uses toISOString slice, deterministic across locales. */
@@ -1174,11 +1293,21 @@ export function reviewFindingTier(f: {
1174
1293
  return "Info";
1175
1294
  }
1176
1295
 
1177
- /** True when a severity tier is high enough to warrant the verify pass. */
1296
+ /**
1297
+ * True when a severity tier is high enough to warrant the verify pass.
1298
+ *
1299
+ * Step 9: only accepted findings at severity >= 7 (with confidence >= 60)
1300
+ * trigger self-verification. Lower findings are not worth a second full LLM
1301
+ * turn; empty, low-severity, or rejected-only findings resubmit pass 1's
1302
+ * plan directly.
1303
+ */
1178
1304
  export function reviewNeedsVerification(findings: ReviewFinding[]): boolean {
1179
1305
  if (!Array.isArray(findings)) return false;
1180
1306
  return findings.some(
1181
- (f) => Number(f?.severity) >= 5 && Number(f?.confidence) >= 60,
1307
+ (f) =>
1308
+ Number(f?.severity) >= 7 &&
1309
+ Number(f?.confidence) >= 60 &&
1310
+ f?.disposition === "accepted",
1182
1311
  );
1183
1312
  }
1184
1313
 
@@ -1285,6 +1414,79 @@ export function renderReviewChangelog(
1285
1414
  return lines.join("\n").trimEnd();
1286
1415
  }
1287
1416
 
1417
+ /**
1418
+ * Minimal unified diff between two plan texts (step 9).
1419
+ *
1420
+ * Pass 2 only checks "did the rewrite drop a requirement", so it gets a
1421
+ * diff — not two full plans. Prefix/suffix trim with a few context lines
1422
+ * keeps typical localized rewrites at ~1/10th the tokens; a total rewrite
1423
+ * degrades to one hunk (no worse than today). Capped and never throws.
1424
+ */
1425
+ export function buildPlanDiff(
1426
+ original: string,
1427
+ revised: string,
1428
+ context = 3,
1429
+ ): string {
1430
+ const a = String(original ?? "").split("\n");
1431
+ const b = String(revised ?? "").split("\n");
1432
+ if (a.join("\n") === b.join("\n")) return "(no differences)";
1433
+ const ctx = Number.isFinite(context)
1434
+ ? Math.max(0, Math.min(10, Math.trunc(Number(context))))
1435
+ : 3;
1436
+ let start = 0;
1437
+ while (start < a.length && start < b.length && a[start] === b[start])
1438
+ start++;
1439
+ let endA = a.length - 1;
1440
+ let endB = b.length - 1;
1441
+ while (endA >= start && endB >= start && a[endA] === b[endB]) {
1442
+ endA--;
1443
+ endB--;
1444
+ }
1445
+ const fromA = Math.max(0, start - ctx);
1446
+ const toA = Math.min(a.length - 1, endA + ctx);
1447
+ const fromB = Math.max(0, start - ctx);
1448
+ const toB = Math.min(b.length - 1, endB + ctx);
1449
+ const out = [
1450
+ "--- original (author)",
1451
+ "+++ rewritten (reviewer)",
1452
+ `@@ -${fromA + 1},${toA - fromA + 1} +${fromB + 1},${toB - fromB + 1} @@`,
1453
+ ];
1454
+ for (let i = fromA; i < start; i++) out.push(` ${a[i]}`);
1455
+ for (let i = start; i <= endA; i++) out.push(`- ${a[i]}`);
1456
+ for (let i = start; i <= endB; i++) out.push(`+ ${b[i]}`);
1457
+ const tailFrom = Math.max(endA + 1, endB + 1, start);
1458
+ const tailTo = Math.max(toA, toB);
1459
+ const tailSrc = toA - fromA >= toB - fromB ? a : b;
1460
+ for (let i = tailFrom; i <= Math.min(tailTo, tailSrc.length - 1); i++)
1461
+ out.push(` ${tailSrc[i]}`);
1462
+ const text = out.join("\n");
1463
+ const MAX_DIFF_CHARS = 12_000;
1464
+ return text.length > MAX_DIFF_CHARS
1465
+ ? `${text.slice(0, MAX_DIFF_CHARS)}\n… [diff truncated — the rewrite is large; re-read the plan file]`
1466
+ : text;
1467
+ }
1468
+
1469
+ /** Outcome of landing a detached background review (steps 5/11). */
1470
+ export type ReviewLanding = "updated" | "unchanged" | "stale";
1471
+
1472
+ /**
1473
+ * Decide what a landed background review may announce (step 11 contract).
1474
+ * Pure: `diskHash` is the plan file on disk, `inputHash` the reviewed
1475
+ * revision, `finalHash` the reviewer's rewrite. Only `updated` (the disk
1476
+ * holds the rewrite) announces a change; every other outcome keeps the
1477
+ * author's/user's text and stays quiet or defers to a re-run.
1478
+ */
1479
+ export function classifyReviewLanding(input: {
1480
+ diskHash?: string;
1481
+ inputHash: string;
1482
+ finalHash: string;
1483
+ }): ReviewLanding {
1484
+ if (input.diskHash === undefined) return "unchanged";
1485
+ if (input.diskHash === input.finalHash) return "updated";
1486
+ if (input.diskHash === input.inputHash) return "unchanged";
1487
+ return "stale";
1488
+ }
1489
+
1288
1490
  /**
1289
1491
  * Strip any previously appended Review Mode appendix from a plan.
1290
1492
  * Returns the plan body exactly as the author wrote it.
@@ -1436,11 +1638,11 @@ export function pruneContextEntriesForReview(
1436
1638
  opts: PruneReviewContextOptions = {},
1437
1639
  ): any[] {
1438
1640
  const maxChars = Number.isFinite(opts.maxToolResultChars)
1439
- ? Math.max(200, Number(opts.maxToolResultChars))
1440
- : 4000;
1641
+ ? Math.min(4000, Math.max(200, Number(opts.maxToolResultChars)))
1642
+ : 1000;
1441
1643
  const maxEntries = Number.isFinite(opts.maxEntries)
1442
- ? Math.max(1, Number(opts.maxEntries))
1443
- : 120;
1644
+ ? Math.min(120, Math.max(1, Number(opts.maxEntries)))
1645
+ : 30;
1444
1646
  if (!Array.isArray(entries)) return [];
1445
1647
  const kept: any[] = [];
1446
1648
  try {
@@ -1554,8 +1756,8 @@ export function renderReviewContextBlock(
1554
1756
  opts: { maxChars?: number } = {},
1555
1757
  ): string {
1556
1758
  const maxChars = Number.isFinite(opts.maxChars)
1557
- ? Math.max(1000, Number(opts.maxChars))
1558
- : 60_000;
1759
+ ? Math.min(60_000, Math.max(1000, Number(opts.maxChars)))
1760
+ : 15_000;
1559
1761
  if (!Array.isArray(entries) || entries.length === 0) return "";
1560
1762
  const lines: string[] = [];
1561
1763
  try {
@@ -1579,67 +1781,240 @@ export function renderReviewContextBlock(
1579
1781
  }
1580
1782
  let out = lines.join("\n\n");
1581
1783
  if (out.length > maxChars) {
1582
- out = `${out.slice(0, maxChars)}\n\n… [context truncated at ${maxChars} chars]`;
1784
+ out = `${out.slice(0, maxChars)}\n\n… [context truncated at ${maxChars} chars — re-read anything you rely on with read/grep]`;
1583
1785
  }
1584
1786
  return out;
1585
1787
  }
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
- }
1788
+
1789
+ // ── Planner-context manifest for the reviewer (step 8) ─────────────
1790
+
1791
+ /** What `buildPlannerContextManifest` found in the parent branch. */
1792
+ export interface PlannerContextManifest {
1793
+ /** Markdown section for the pass-1 prompt ("" when there is nothing). */
1794
+ markdown: string;
1795
+ /** Distinct file paths inventoried. */
1796
+ files: number;
1797
+ /** Explore recon summaries included. */
1798
+ reconResults: number;
1799
+ }
1800
+
1801
+ export interface BuildPlannerManifestOptions {
1802
+ /** Max files listed in the inventory. */
1803
+ maxFiles?: number;
1804
+ /** Max explore results summarized. */
1805
+ maxReconResults?: number;
1806
+ /** Max chars taken from the tail of each explore result. */
1807
+ maxReconChars?: number;
1808
+ /**
1809
+ * Describe a referenced path (size/mtime/hash token). Return undefined
1810
+ * for paths that cannot be described — the path is still listed.
1811
+ */
1812
+ describeFile?: (path: string) => string | undefined;
1813
+ }
1814
+
1815
+ const PLANNER_MANIFEST_MAX_FILES = 200;
1816
+ const PLANNER_MANIFEST_MAX_RECON = 3;
1817
+ const PLANNER_MANIFEST_RECON_CHARS = 700;
1818
+
1819
+ /**
1820
+ * Ordered unique `path`/`file_path` arguments from tool calls in the branch.
1821
+ * These are the files Plan Mode touched — the reviewer can trust them
1822
+ * unless a finding cites them (then it re-reads). Never throws.
1823
+ */
1824
+ function manifestToolPaths(entries: unknown, maxFiles: number): string[] {
1825
+ const paths: string[] = [];
1826
+ const seen = new Set<string>();
1827
+ const take = (raw: unknown): void => {
1828
+ if (paths.length >= maxFiles) return;
1829
+ if (typeof raw !== "string") return;
1830
+ const p = raw.trim();
1831
+ if (!p || p.length > 500 || seen.has(p)) return;
1832
+ seen.add(p);
1833
+ paths.push(p);
1834
+ };
1835
+ try {
1836
+ if (!Array.isArray(entries)) return paths;
1837
+ for (const e of entries) {
1838
+ if (paths.length >= maxFiles) break;
1839
+ if (!e || typeof e !== "object") continue;
1840
+ const blocks = (e as { message?: { content?: unknown } }).message
1841
+ ?.content;
1842
+ if (!Array.isArray(blocks)) continue;
1843
+ for (const b of blocks) {
1844
+ if (!b || typeof b !== "object") continue;
1845
+ const t = (b as { type?: unknown }).type;
1846
+ if (t !== "toolCall" && t !== "tool_use") continue;
1847
+ const args = (b as { arguments?: unknown; input?: unknown })
1848
+ .arguments ?? (b as { input?: unknown }).input;
1849
+ if (args && typeof args === "object") {
1850
+ take((args as Record<string, unknown>).path);
1851
+ take((args as Record<string, unknown>).file_path);
1852
+ }
1853
+ }
1854
+ }
1855
+ } catch {
1856
+ /* keep what was collected */
1857
+ }
1858
+ return paths;
1859
+ }
1860
+
1861
+ /**
1862
+ * Tails of the most recent `explore` tool results (Plan Mode recon), newest
1863
+ * first. Conclusions tend to live at the end, so the tail is kept. Never throws.
1864
+ */
1865
+ function manifestReconTails(
1866
+ entries: unknown,
1867
+ maxResults: number,
1868
+ maxChars: number,
1869
+ ): string[] {
1870
+ const tails: string[] = [];
1871
+ try {
1872
+ if (!Array.isArray(entries)) return tails;
1873
+ for (
1874
+ let i = entries.length - 1;
1875
+ i >= 0 && tails.length < maxResults;
1876
+ i--
1877
+ ) {
1878
+ const msg = (entries[i] as { message?: unknown })?.message as
1879
+ | { role?: unknown; toolName?: unknown; content?: unknown }
1880
+ | undefined;
1881
+ if (!msg || msg.role !== "toolResult" || msg.toolName !== "explore")
1882
+ continue;
1883
+ const text = contentBlocksToText(msg.content).trim();
1884
+ if (!text) continue;
1885
+ tails.push(
1886
+ text.length > maxChars ? `…${text.slice(text.length - maxChars)}` : text,
1887
+ );
1888
+ }
1889
+ } catch {
1890
+ /* keep what was collected */
1891
+ }
1892
+ return tails;
1893
+ }
1894
+
1895
+ /**
1896
+ * Build the Planner-context manifest: a file inventory with change tokens
1897
+ * plus recent explore recon, so the reviewer verifies instead of re-exploring
1898
+ * (plan 2026-10-23, step 8). Takes the RAW branch — pruned entries elide the
1899
+ * explore tails this needs. Pure except for the optional `describeFile`.
1900
+ * Never throws; empty input yields an empty markdown section.
1901
+ */
1902
+ export function buildPlannerContextManifest(
1903
+ entries: unknown,
1904
+ opts: BuildPlannerManifestOptions = {},
1905
+ ): PlannerContextManifest {
1906
+ const empty: PlannerContextManifest = {
1907
+ markdown: "",
1908
+ files: 0,
1909
+ reconResults: 0,
1910
+ };
1911
+ try {
1912
+ const maxFiles = Number.isFinite(opts.maxFiles)
1913
+ ? Math.max(1, Math.min(500, Number(opts.maxFiles)))
1914
+ : PLANNER_MANIFEST_MAX_FILES;
1915
+ const maxRecon = Number.isFinite(opts.maxReconResults)
1916
+ ? Math.max(0, Math.min(10, Number(opts.maxReconResults)))
1917
+ : PLANNER_MANIFEST_MAX_RECON;
1918
+ const maxReconChars = Number.isFinite(opts.maxReconChars)
1919
+ ? Math.max(100, Math.min(8000, Number(opts.maxReconChars)))
1920
+ : PLANNER_MANIFEST_RECON_CHARS;
1921
+ const paths = manifestToolPaths(entries, maxFiles);
1922
+ const recon = manifestReconTails(entries, maxRecon, maxReconChars);
1923
+ if (paths.length === 0 && recon.length === 0) return empty;
1924
+ const lines = [
1925
+ "## Shared context (Planner — trusted unless a finding cites it)",
1926
+ "",
1927
+ "Plan Mode already explored this repository. Trust this inventory and",
1928
+ "these recon summaries for everything they cover; re-read only files a",
1929
+ "finding anchors, plus one or two spot checks.",
1930
+ "",
1931
+ ];
1932
+ if (paths.length > 0) {
1933
+ lines.push(`Files Plan Mode touched (${paths.length}):`, "");
1934
+ for (const p of paths) {
1935
+ let detail = "";
1936
+ try {
1937
+ const d = opts.describeFile?.(p);
1938
+ if (d) detail = ` — ${d}`;
1939
+ } catch {
1940
+ /* a path is still listed without its token */
1941
+ }
1942
+ lines.push(`- \`${p}\`${detail}`);
1943
+ }
1944
+ lines.push("");
1945
+ }
1946
+ if (recon.length > 0) {
1947
+ lines.push(`Planner recon (newest first, ${recon.length}):`, "");
1948
+ recon.forEach((tail, i) => {
1949
+ lines.push(`### Recon ${i + 1}`, "", tail, "");
1950
+ });
1951
+ }
1952
+ return {
1953
+ markdown: lines.join("\n").trimEnd(),
1954
+ files: paths.length,
1955
+ reconResults: recon.length,
1956
+ };
1957
+ } catch {
1958
+ return empty;
1959
+ }
1960
+ }
1961
+
1962
+ // ── Slash-command argument completions (pi-tui contract) ─────────────
1963
+ //
1964
+ // `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
1965
+ // passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
1966
+ // text after the command name and, on accept, `applyCompletion` replaces
1967
+ // that whole argument text with `item.value` (the slash-argument branch:
1968
+ // `newLine = beforePrefix + item.value`). So an argument-position item's
1969
+ // `value` must be the complete replacement — e.g. `"use <name>"`, never the
1970
+ // bare token. Pi's own /mcp extension follows this contract
1971
+ // (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
1972
+ // that enforces it: it keeps every committed token and appends (or replaces
1973
+ // the last partial token with) `token`.
1974
+
1975
+ /** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
1976
+ export interface ArgumentCompletion {
1977
+ value: string;
1978
+ label: string;
1979
+ description?: string;
1980
+ }
1981
+
1982
+ /**
1983
+ * Build the full-argument replacement `value` for a completion token.
1984
+ *
1985
+ * `prefix` is the typed argument text so far. Tokens separated by whitespace
1986
+ * are "committed"; if `prefix` ends with whitespace the user is starting a
1987
+ * fresh token, so `token` is appended — otherwise it replaces the trailing
1988
+ * partial token. Pure; never throws.
1989
+ *
1990
+ * joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
1991
+ * → "use Syn-Deepseek-v4.1-Flash-Planner"
1992
+ * joinArgumentValue("use ", "none") → "use none"
1993
+ * joinArgumentValue("", "list") → "list"
1994
+ */
1995
+ export function joinArgumentValue(prefix: string, token: string): string {
1996
+ const raw = String(prefix ?? "");
1997
+ const endsWithWs = /\s$/.test(raw);
1998
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
1999
+ const committed = endsWithWs ? parts : parts.slice(0, -1);
2000
+ return [...committed, token].join(" ");
2001
+ }
2002
+
2003
+ /**
2004
+ * Exact-match suppression for slash-command argument completions: when the
2005
+ * token being typed (`partial`, "" while the prefix ends with whitespace)
2006
+ * already equals the ONLY remaining candidate, return true so the completer
2007
+ * yields null — pi's editor then closes the dropdown and the FIRST Enter
2008
+ * submits instead of being consumed by the completion-accept handler
2009
+ * (editor.js `tui.select.confirm`). Pure.
2010
+ */
2011
+ export function suppressExactArgumentMatch(
2012
+ candidates: readonly string[],
2013
+ partial: string,
2014
+ ): boolean {
2015
+ if (!partial) return false;
2016
+ return (
2017
+ candidates.length === 1 &&
2018
+ candidates[0].toLowerCase() === partial.toLowerCase()
2019
+ );
2020
+ }