@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/README.md +111 -28
- package/commit-policy.ts +43 -0
- package/index.ts +3017 -1119
- package/model-default.ts +535 -0
- package/package.json +8 -2
- package/picker.ts +246 -0
- package/profiles.ts +73 -0
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +267 -76
- 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 +444 -69
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
|
-
/**
|
|
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) =>
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
// ──
|
|
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
|
-
}
|
|
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
|
+
}
|