pi-do-always 0.13.0 → 0.16.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.
|
@@ -67,6 +67,25 @@ export interface DoAlwaysTask {
|
|
|
67
67
|
* configs that predate this field keep working.
|
|
68
68
|
*/
|
|
69
69
|
browser?: BrowserType;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the plan questionnaire is offered after this task's run: the
|
|
72
|
+
* reply's "plan" block (tiers + action items) becomes a selectable list
|
|
73
|
+
* the user confirms or withdraws. Default true (the global config
|
|
74
|
+
* "questionnaire" sets the fallback); set false to keep the plain summary
|
|
75
|
+
* notification. Only meaningful for auto-run tasks (Plan category by
|
|
76
|
+
* default).
|
|
77
|
+
*/
|
|
78
|
+
questionnaire?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Whether the raw "plan" block is hidden from the transcript after this
|
|
81
|
+
* task's run: the fenced block is stripped from the finalized reply in
|
|
82
|
+
* the TUI (the questionnaire still parses the captured raw text; in
|
|
83
|
+
* non-TUI modes the block is always kept). Default true (the global
|
|
84
|
+
* config "hidePlan" sets the fallback); set false to keep the block
|
|
85
|
+
* visible in the transcript. Only meaningful for auto-run tasks (Plan
|
|
86
|
+
* category by default).
|
|
87
|
+
*/
|
|
88
|
+
hidePlan?: boolean;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
/** The set of known guard types (used for validation at parse time). */
|
|
@@ -115,6 +134,19 @@ type DoAlwaysConfig =
|
|
|
115
134
|
* the project root). Default true; set false to disable.
|
|
116
135
|
*/
|
|
117
136
|
report?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Whether completed auto-run tasks whose reply carries a "plan"
|
|
139
|
+
* block offer the selection questionnaire. Default true; set false
|
|
140
|
+
* to keep the plain summary notification.
|
|
141
|
+
*/
|
|
142
|
+
questionnaire?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Whether the raw "plan" block is stripped from the transcript
|
|
145
|
+
* after a completed auto-run task (TUI only — in non-TUI modes
|
|
146
|
+
* the block is always kept). Default true; set false to keep the
|
|
147
|
+
* block visible in the transcript.
|
|
148
|
+
*/
|
|
149
|
+
hidePlan?: boolean;
|
|
118
150
|
};
|
|
119
151
|
|
|
120
152
|
/** Shortcut used when neither config file specifies one. */
|
|
@@ -138,6 +170,18 @@ interface ParsedDoAlwaysConfig {
|
|
|
138
170
|
* report file. undefined when the file does not set one (default: on).
|
|
139
171
|
*/
|
|
140
172
|
report: boolean | undefined;
|
|
173
|
+
/**
|
|
174
|
+
* The `questionnaire` field, if present: whether completed auto-run
|
|
175
|
+
* tasks whose reply carries a plan block offer the selection
|
|
176
|
+
* questionnaire. undefined when the file does not set one (default: on).
|
|
177
|
+
*/
|
|
178
|
+
questionnaire: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* The `hidePlan` field, if present: whether the raw plan block is
|
|
181
|
+
* stripped from the transcript after a completed auto-run task.
|
|
182
|
+
* undefined when the file does not set one (default: on).
|
|
183
|
+
*/
|
|
184
|
+
hidePlan: boolean | undefined;
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
import { existsSync } from "node:fs";
|
|
@@ -418,7 +462,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
418
462
|
description: "Prepare a release (version, changelog, tag)",
|
|
419
463
|
when: "git",
|
|
420
464
|
prompt:
|
|
421
|
-
"Prepare a release for this project (branch {{branch}}): check `git log` since the last tag,
|
|
465
|
+
"Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, then bump the version in package.json (or the equivalent location) to the next version, and add a changelog entry under that exact version summarizing the changes. The changelog entry must use the same version number now set in package.json — never add a changelog section for a version that package.json does not yet contain, and never leave an 'Unreleased' or placeholder version heading. Create a git tag if git present. Do not push.",
|
|
422
466
|
},
|
|
423
467
|
{
|
|
424
468
|
name: "Commit",
|
|
@@ -474,14 +518,14 @@ export function parseConfig(
|
|
|
474
518
|
data = JSON.parse(raw);
|
|
475
519
|
} catch (err) {
|
|
476
520
|
onError(`do-always: invalid JSON in ${path}: ${err}`);
|
|
477
|
-
return { tasks: [], shortcut: undefined, report: undefined };
|
|
521
|
+
return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
478
522
|
}
|
|
479
523
|
|
|
480
524
|
const list = Array.isArray(data) ? data : data?.tasks;
|
|
481
525
|
|
|
482
526
|
if (!Array.isArray(list)) {
|
|
483
527
|
onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
|
|
484
|
-
return { tasks: [], shortcut: undefined, report: undefined };
|
|
528
|
+
return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
485
529
|
}
|
|
486
530
|
|
|
487
531
|
const tasks: DoAlwaysTask[] = [];
|
|
@@ -498,6 +542,8 @@ export function parseConfig(
|
|
|
498
542
|
if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
|
|
499
543
|
if (typeof t.hidden === "boolean") task.hidden = t.hidden;
|
|
500
544
|
if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
|
|
545
|
+
if (typeof t.questionnaire === "boolean") task.questionnaire = t.questionnaire;
|
|
546
|
+
if (typeof t.hidePlan === "boolean") task.hidePlan = t.hidePlan;
|
|
501
547
|
if (t.browser !== undefined) {
|
|
502
548
|
if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
|
|
503
549
|
task.browser = t.browser as BrowserType;
|
|
@@ -546,8 +592,18 @@ export function parseConfig(
|
|
|
546
592
|
if (typeof data.report === "boolean") report = data.report;
|
|
547
593
|
else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
|
|
548
594
|
}
|
|
595
|
+
let questionnaire: boolean | undefined;
|
|
596
|
+
if (!Array.isArray(data) && "questionnaire" in data) {
|
|
597
|
+
if (typeof data.questionnaire === "boolean") questionnaire = data.questionnaire;
|
|
598
|
+
else onError(`do-always: ignoring invalid "questionnaire" in ${path} (expected true or false)`);
|
|
599
|
+
}
|
|
600
|
+
let hidePlan: boolean | undefined;
|
|
601
|
+
if (!Array.isArray(data) && "hidePlan" in data) {
|
|
602
|
+
if (typeof data.hidePlan === "boolean") hidePlan = data.hidePlan;
|
|
603
|
+
else onError(`do-always: ignoring invalid "hidePlan" in ${path} (expected true or false)`);
|
|
604
|
+
}
|
|
549
605
|
|
|
550
|
-
return { tasks, shortcut, merge, report };
|
|
606
|
+
return { tasks, shortcut, merge, report, questionnaire, hidePlan };
|
|
551
607
|
}
|
|
552
608
|
|
|
553
609
|
const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
@@ -807,7 +863,7 @@ export function orderTasksByCategory(
|
|
|
807
863
|
*/
|
|
808
864
|
export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
809
865
|
if (typeof task.autoRun === "boolean") return task.autoRun;
|
|
810
|
-
return (task
|
|
866
|
+
return isPlanTask(task);
|
|
811
867
|
}
|
|
812
868
|
|
|
813
869
|
/**
|
|
@@ -1491,3 +1547,352 @@ export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
|
|
|
1491
1547
|
`Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
|
|
1492
1548
|
);
|
|
1493
1549
|
}
|
|
1550
|
+
|
|
1551
|
+
// ── Plan proposal ────────────────────────────────────────────────────────────
|
|
1552
|
+
//
|
|
1553
|
+
// Auto-run Plan tasks end their reply with a machine-readable plan block:
|
|
1554
|
+
// a fenced code block tagged "plan" carrying the proposed action items as
|
|
1555
|
+
// JSON (summary + tiers of items). PLAN_OUTPUT_INSTRUCTION is appended to
|
|
1556
|
+
// Plan task prompts at render time so the agent emits the block;
|
|
1557
|
+
// parsePlanProposal extracts and normalizes it; the selection state machine
|
|
1558
|
+
// and the execution prompt builder are pure so they can be unit-tested
|
|
1559
|
+
// without the Pi runtime.
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Instruction appended to Plan-category task prompts at render time: it
|
|
1563
|
+
* requires the reply to end with a fenced "plan" code block containing the
|
|
1564
|
+
* proposed action items as JSON (summary + tiers of items). Kept in one
|
|
1565
|
+
* place so every Plan prompt shares the same contract.
|
|
1566
|
+
*/
|
|
1567
|
+
export const PLAN_OUTPUT_INSTRUCTION =
|
|
1568
|
+
"End your reply with a machine-readable plan block: a fenced code block tagged plan (```plan) containing JSON of exactly this shape: " +
|
|
1569
|
+
'{"summary":"one-line summary","tiers":[{"id":"P0","label":"Critical","items":[{"title":"short action","detail":"where and why (file:line if known)"}]}]}. ' +
|
|
1570
|
+
"One tier per priority level, most urgent first (P0, P1, P2, ...). " +
|
|
1571
|
+
"Each item must be one concrete, independently doable action. " +
|
|
1572
|
+
'Use an empty "tiers" array when no action is needed.';
|
|
1573
|
+
|
|
1574
|
+
/** One concrete action item proposed by a Plan run. */
|
|
1575
|
+
export interface PlanItem {
|
|
1576
|
+
/** Short action description. */
|
|
1577
|
+
title: string;
|
|
1578
|
+
/** Where and why — file:line, rationale (optional). */
|
|
1579
|
+
detail?: string;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** A priority tier grouping action items (most urgent tier first). */
|
|
1583
|
+
export interface PlanTier {
|
|
1584
|
+
/** Tier id as emitted by the agent (e.g. "P0"); defaulted by position when absent. */
|
|
1585
|
+
id: string;
|
|
1586
|
+
/** Human label (e.g. "Critical"); defaults to the id when absent. */
|
|
1587
|
+
label: string;
|
|
1588
|
+
/** The tier's action items, in the order the agent emitted them. */
|
|
1589
|
+
items: PlanItem[];
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** A parsed plan proposal: the agent's reply, normalized for selection. */
|
|
1593
|
+
export interface PlanProposal {
|
|
1594
|
+
/** One-line summary from the block (undefined when absent or blank). */
|
|
1595
|
+
summary?: string;
|
|
1596
|
+
/** Tiers in the order the agent emitted them (most urgent first). */
|
|
1597
|
+
tiers: PlanTier[];
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/** True when the task belongs to the Plan category (case-insensitive). */
|
|
1601
|
+
export function isPlanTask(task: DoAlwaysTask): boolean {
|
|
1602
|
+
return (task.category ?? "").trim().toLowerCase() === "plan";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Matches a fenced code block whose info string is "plan" (trailing
|
|
1607
|
+
* whitespace allowed). The lazy body stops at the first closing fence.
|
|
1608
|
+
*/
|
|
1609
|
+
const PLAN_FENCE_RE = /```plan[^\S\n]*\r?\n([\s\S]*?)```/gi;
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Remove every fenced plan block from `text`, collapsing the blank lines
|
|
1613
|
+
* they leave behind and trimming the ends. The stripped text is what the
|
|
1614
|
+
* transcript shows (the message_end handler in index.ts replaces the
|
|
1615
|
+
* finalized message with it); the raw text is captured separately for the
|
|
1616
|
+
* questionnaire parser. `removed` is false when no plan fence was present
|
|
1617
|
+
* (the text is returned unchanged).
|
|
1618
|
+
*/
|
|
1619
|
+
export function stripPlanBlocks(text: string): { text: string; removed: boolean } {
|
|
1620
|
+
if (typeof text !== "string" || text === "") return { text, removed: false };
|
|
1621
|
+
if (!text.includes("```plan")) return { text, removed: false };
|
|
1622
|
+
// An unclosed fence (no closing ```) matches nothing: leave the text
|
|
1623
|
+
// alone instead of claiming a removal that didn't happen.
|
|
1624
|
+
if ([...text.matchAll(PLAN_FENCE_RE)].length === 0) return { text, removed: false };
|
|
1625
|
+
const stripped = text
|
|
1626
|
+
.replace(PLAN_FENCE_RE, "")
|
|
1627
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1628
|
+
.replace(/^\s+/, "")
|
|
1629
|
+
.replace(/\s+$/, "");
|
|
1630
|
+
return { text: stripped, removed: true };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Parse a plan proposal from a Plan run's reply text. Finds the fenced
|
|
1635
|
+
* "plan" code blocks, tries them from last to first (the agent may emit an
|
|
1636
|
+
* early malformed one and correct it), and returns the first that yields at
|
|
1637
|
+
* least one valid item. Returns null when there is no plan block, the JSON
|
|
1638
|
+
* is malformed, or nothing valid survives normalization — callers then fall
|
|
1639
|
+
* back to the plain summary notification.
|
|
1640
|
+
*/
|
|
1641
|
+
export function parsePlanProposal(text: string): PlanProposal | null {
|
|
1642
|
+
if (typeof text !== "string" || text === "") return null;
|
|
1643
|
+
const fences = [...text.matchAll(PLAN_FENCE_RE)];
|
|
1644
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
1645
|
+
const proposal = normalizePlanJson(fences[i][1]);
|
|
1646
|
+
if (proposal) return proposal;
|
|
1647
|
+
}
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Why (or why not) a reply's plan block is usable for the questionnaire.
|
|
1653
|
+
* Mirrors parsePlanProposal's block precedence (last to first), so the
|
|
1654
|
+
* reported reason matches what the parser decided: "ok" is exactly the case
|
|
1655
|
+
* where parsePlanProposal returns a proposal.
|
|
1656
|
+
*/
|
|
1657
|
+
export type PlanBlockDiagnostic =
|
|
1658
|
+
| { kind: "none" } // no fenced plan block at all
|
|
1659
|
+
| { kind: "malformed"; detail: string } // the last block's JSON does not parse (detail: the parse error)
|
|
1660
|
+
| { kind: "empty" } // JSON parsed, but no valid items survived normalization
|
|
1661
|
+
| { kind: "ok"; itemCount: number };
|
|
1662
|
+
|
|
1663
|
+
export function planBlockDiagnostics(text: string): PlanBlockDiagnostic {
|
|
1664
|
+
if (typeof text !== "string" || text === "") return { kind: "none" };
|
|
1665
|
+
const fences = [...text.matchAll(PLAN_FENCE_RE)];
|
|
1666
|
+
if (fences.length === 0) return { kind: "none" };
|
|
1667
|
+
let lastError: string | null = null;
|
|
1668
|
+
let parsedButEmpty = false;
|
|
1669
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
1670
|
+
try {
|
|
1671
|
+
JSON.parse(fences[i][1].trim());
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
// The loop runs last-to-first, so the first error seen is the
|
|
1674
|
+
// last block's — the agent's final answer, which is the one to
|
|
1675
|
+
// report. Strip the position suffix from JSON.parse errors
|
|
1676
|
+
// ("at position N (line L column C)") so the message is readable
|
|
1677
|
+
// in a large plan block where the position number is meaningless.
|
|
1678
|
+
if (lastError === null) {
|
|
1679
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
1680
|
+
lastError = raw.replace(/\s+at\s+position\s+\d+(?:\s*\(line\s+\d+\s+column\s+\d+\))?/, "");
|
|
1681
|
+
}
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
const proposal = normalizePlanJson(fences[i][1]);
|
|
1685
|
+
if (proposal) {
|
|
1686
|
+
const count = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
|
|
1687
|
+
if (count > 0) return { kind: "ok", itemCount: count };
|
|
1688
|
+
}
|
|
1689
|
+
parsedButEmpty = true;
|
|
1690
|
+
}
|
|
1691
|
+
if (lastError !== null) return { kind: "malformed", detail: lastError };
|
|
1692
|
+
if (parsedButEmpty) return { kind: "empty" };
|
|
1693
|
+
return { kind: "malformed", detail: "invalid JSON" };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Normalize one plan block's JSON body. Accepts the tiered shape
|
|
1698
|
+
* ({tiers: [{id, label, items: [...]}]}) and a lenient flat shape
|
|
1699
|
+
* ({items: [{tier, title, ...}]}, grouped preserving first-seen tier
|
|
1700
|
+
* order). Invalid entries are dropped; a result with no valid items is
|
|
1701
|
+
* null.
|
|
1702
|
+
*/
|
|
1703
|
+
function normalizePlanJson(raw: string): PlanProposal | null {
|
|
1704
|
+
let data: unknown;
|
|
1705
|
+
try {
|
|
1706
|
+
data = JSON.parse(raw.trim());
|
|
1707
|
+
} catch {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
1711
|
+
const obj = data as Record<string, unknown>;
|
|
1712
|
+
const summary =
|
|
1713
|
+
typeof obj.summary === "string" && obj.summary.trim() !== "" ? obj.summary.trim() : undefined;
|
|
1714
|
+
const tiers: PlanTier[] = [];
|
|
1715
|
+
if (Array.isArray(obj.tiers)) {
|
|
1716
|
+
obj.tiers.forEach((t, i) => {
|
|
1717
|
+
const tier = normalizePlanTier(t, i);
|
|
1718
|
+
if (tier) tiers.push(tier);
|
|
1719
|
+
});
|
|
1720
|
+
} else if (Array.isArray(obj.items)) {
|
|
1721
|
+
const byTier = new Map<string, PlanTier>();
|
|
1722
|
+
for (const entry of obj.items) {
|
|
1723
|
+
const item = normalizePlanItem(entry);
|
|
1724
|
+
if (!item) continue;
|
|
1725
|
+
const tierField =
|
|
1726
|
+
typeof entry === "object" && entry !== null
|
|
1727
|
+
? (entry as Record<string, unknown>).tier
|
|
1728
|
+
: undefined;
|
|
1729
|
+
const id = typeof tierField === "string" && tierField.trim() !== "" ? tierField.trim() : "P0";
|
|
1730
|
+
if (!byTier.has(id)) byTier.set(id, { id, label: id, items: [] });
|
|
1731
|
+
byTier.get(id)!.items.push(item);
|
|
1732
|
+
}
|
|
1733
|
+
for (const tier of byTier.values()) tiers.push(tier);
|
|
1734
|
+
}
|
|
1735
|
+
if (tiers.length === 0) return null;
|
|
1736
|
+
return { summary, tiers };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Normalize one tier entry; null when it carries no valid items. */
|
|
1740
|
+
function normalizePlanTier(raw: unknown, index: number): PlanTier | null {
|
|
1741
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1742
|
+
const obj = raw as Record<string, unknown>;
|
|
1743
|
+
const items: PlanItem[] = [];
|
|
1744
|
+
if (Array.isArray(obj.items)) {
|
|
1745
|
+
for (const entry of obj.items) {
|
|
1746
|
+
const item = normalizePlanItem(entry);
|
|
1747
|
+
if (item) items.push(item);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (items.length === 0) return null;
|
|
1751
|
+
const id = typeof obj.id === "string" && obj.id.trim() !== "" ? obj.id.trim() : `P${index}`;
|
|
1752
|
+
const label = typeof obj.label === "string" && obj.label.trim() !== "" ? obj.label.trim() : id;
|
|
1753
|
+
return { id, label, items };
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Normalize one item entry: a plain string is a title-only item; an object
|
|
1758
|
+
* needs a non-blank string "title" ("detail" is optional). Null otherwise.
|
|
1759
|
+
*/
|
|
1760
|
+
function normalizePlanItem(raw: unknown): PlanItem | null {
|
|
1761
|
+
if (typeof raw === "string") {
|
|
1762
|
+
const title = raw.trim();
|
|
1763
|
+
return title === "" ? null : { title };
|
|
1764
|
+
}
|
|
1765
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1766
|
+
const obj = raw as Record<string, unknown>;
|
|
1767
|
+
const title = typeof obj.title === "string" ? obj.title.trim() : "";
|
|
1768
|
+
if (title === "") return null;
|
|
1769
|
+
const item: PlanItem = { title };
|
|
1770
|
+
const detail = typeof obj.detail === "string" ? obj.detail.trim() : "";
|
|
1771
|
+
if (detail !== "") item.detail = detail;
|
|
1772
|
+
return item;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ── Plan questionnaire selection ─────────────────────────────────────────────
|
|
1776
|
+
//
|
|
1777
|
+
// The user's selection in the plan questionnaire: which item keys are
|
|
1778
|
+
// checked. A key is "tierIndex:itemIndex" (0-based positions in the
|
|
1779
|
+
// normalized proposal). Pure state — every operation returns a new Set,
|
|
1780
|
+
// never mutating the input (same style as the chain state).
|
|
1781
|
+
|
|
1782
|
+
/** The checked item keys of one questionnaire. */
|
|
1783
|
+
export type PlanSelection = Set<string>;
|
|
1784
|
+
|
|
1785
|
+
/** An empty selection. */
|
|
1786
|
+
export function planSelectionClear(): PlanSelection {
|
|
1787
|
+
return new Set();
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** The selection key of one item (0-based tier and item positions). */
|
|
1791
|
+
export function planItemKey(tierIndex: number, itemIndex: number): string {
|
|
1792
|
+
return `${tierIndex}:${itemIndex}`;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Toggle one item's membership. */
|
|
1796
|
+
export function planToggleItem(selection: PlanSelection, key: string): PlanSelection {
|
|
1797
|
+
const next = new Set(selection);
|
|
1798
|
+
if (next.has(key)) next.delete(key);
|
|
1799
|
+
else next.add(key);
|
|
1800
|
+
return next;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Toggle a whole tier: fully selected → clear all its items; none or
|
|
1805
|
+
* partial → select all of them. Returns the new selection and whether the
|
|
1806
|
+
* tier ended up selected. Unknown tier index: the selection is unchanged.
|
|
1807
|
+
*/
|
|
1808
|
+
export function planToggleTier(
|
|
1809
|
+
proposal: PlanProposal,
|
|
1810
|
+
tierIndex: number,
|
|
1811
|
+
selection: PlanSelection,
|
|
1812
|
+
): { selection: PlanSelection; selected: boolean } {
|
|
1813
|
+
const tier = proposal.tiers[tierIndex];
|
|
1814
|
+
if (!tier) return { selection, selected: false };
|
|
1815
|
+
const keys = tier.items.map((_, i) => planItemKey(tierIndex, i));
|
|
1816
|
+
const allSelected = keys.every((k) => selection.has(k));
|
|
1817
|
+
const next = new Set(selection);
|
|
1818
|
+
if (allSelected) for (const k of keys) next.delete(k);
|
|
1819
|
+
else for (const k of keys) next.add(k);
|
|
1820
|
+
return { selection: next, selected: !allSelected };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** Select every item in the proposal. */
|
|
1824
|
+
export function planSelectAll(proposal: PlanProposal, selection: PlanSelection): PlanSelection {
|
|
1825
|
+
const next = new Set(selection);
|
|
1826
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1827
|
+
tier.items.forEach((_, ii) => next.add(planItemKey(ti, ii)));
|
|
1828
|
+
});
|
|
1829
|
+
return next;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** One tier's aggregate state: no items, some, or all items selected. */
|
|
1833
|
+
export function planTierState(
|
|
1834
|
+
proposal: PlanProposal,
|
|
1835
|
+
tierIndex: number,
|
|
1836
|
+
selection: PlanSelection,
|
|
1837
|
+
): "none" | "partial" | "all" {
|
|
1838
|
+
const tier = proposal.tiers[tierIndex];
|
|
1839
|
+
if (!tier) return "none";
|
|
1840
|
+
let count = 0;
|
|
1841
|
+
tier.items.forEach((_, ii) => {
|
|
1842
|
+
if (selection.has(planItemKey(tierIndex, ii))) count++;
|
|
1843
|
+
});
|
|
1844
|
+
if (count === 0) return "none";
|
|
1845
|
+
if (count === tier.items.length) return "all";
|
|
1846
|
+
return "partial";
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** A selected item with its tier, for display and the execution prompt. */
|
|
1850
|
+
export interface PlanSelectionEntry {
|
|
1851
|
+
tier: PlanTier;
|
|
1852
|
+
item: PlanItem;
|
|
1853
|
+
/** A user note added in the questionnaire for this item, if any. */
|
|
1854
|
+
note?: string;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* The selected items in execution order: tier order, item order within a
|
|
1859
|
+
* tier. Empty when nothing is selected. When `notes` is given, a non-empty
|
|
1860
|
+
* note for a selected item (keyed by planItemKey) is carried on the entry.
|
|
1861
|
+
*/
|
|
1862
|
+
export function planSelectedItems(
|
|
1863
|
+
proposal: PlanProposal,
|
|
1864
|
+
selection: PlanSelection,
|
|
1865
|
+
notes?: ReadonlyMap<string, string>,
|
|
1866
|
+
): PlanSelectionEntry[] {
|
|
1867
|
+
const out: PlanSelectionEntry[] = [];
|
|
1868
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1869
|
+
tier.items.forEach((item, ii) => {
|
|
1870
|
+
const key = planItemKey(ti, ii);
|
|
1871
|
+
if (!selection.has(key)) return;
|
|
1872
|
+
const note = notes?.get(key)?.trim();
|
|
1873
|
+
out.push(note ? { tier, item, note } : { tier, item });
|
|
1874
|
+
});
|
|
1875
|
+
});
|
|
1876
|
+
return out;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Build the follow-up prompt sent when the user confirms a questionnaire
|
|
1881
|
+
* selection. The proposal itself is already in the conversation (the Plan
|
|
1882
|
+
* run's reply), so the prompt references it and lists only the selected
|
|
1883
|
+
* items, in execution order.
|
|
1884
|
+
*/
|
|
1885
|
+
export function formatPlanExecutionPrompt(selected: PlanSelectionEntry[], taskName: string): string {
|
|
1886
|
+
const lines: string[] = [
|
|
1887
|
+
`Execute the following action items from the "${taskName}" plan proposal, in exactly this order. ` +
|
|
1888
|
+
"Do ONLY these items — skip every other item from the proposal, and do not start anything else. " +
|
|
1889
|
+
"When done, summarize what you changed.",
|
|
1890
|
+
"",
|
|
1891
|
+
];
|
|
1892
|
+
selected.forEach(({ tier, item, note }, i) => {
|
|
1893
|
+
lines.push(
|
|
1894
|
+
`${i + 1}. [${tier.id}] ${item.title}${item.detail ? ` — ${item.detail}` : ""}${note ? ` [note: ${note}]` : ""}`,
|
|
1895
|
+
);
|
|
1896
|
+
});
|
|
1897
|
+
return lines.join("\n");
|
|
1898
|
+
}
|