@agent-plan/pi-adapter 0.2.26 → 0.2.28
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +1352 -678
- package/package.json +3 -3
- package/web-ui-dist/assets/index-DBJc9tGn.css +1 -0
- package/web-ui-dist/assets/index-NRDThOAB.js +329 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-C9NZu-jP.css +0 -1
- package/web-ui-dist/assets/index-DOhGgMMb.js +0 -328
package/dist/index.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { paginatedSelect, paginatedNotify } from "./ui/paginate.js";
|
|
13
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion,
|
|
13
|
+
import { ExportService, PlanStore, PlanStoreError, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_SOURCE_REVIEWS, HANDOFF_COLD_START_INVENTORY_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, HandoffContractError } from "@agent-plan/core";
|
|
14
14
|
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
15
15
|
import { join, dirname } from "node:path";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
18
|
import { writeFile, rm } from "node:fs/promises";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import { createServer } from "node:net";
|
|
20
21
|
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { serve } from "@agent-plan/server/serve";
|
|
@@ -93,6 +94,15 @@ function mutationNoFieldsFailure(input) {
|
|
|
93
94
|
details: outcome,
|
|
94
95
|
};
|
|
95
96
|
}
|
|
97
|
+
function acceptedDecisionMutationFailure(error, outcome) {
|
|
98
|
+
if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
|
|
99
|
+
throw error;
|
|
100
|
+
return {
|
|
101
|
+
isError: true,
|
|
102
|
+
content: [{ type: "text", text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}` }],
|
|
103
|
+
details: { [outcome]: false, ...error.details },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
96
106
|
function legacyContextMigrationSummary(preview) {
|
|
97
107
|
return [
|
|
98
108
|
`Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
|
|
@@ -138,10 +148,12 @@ function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
|
|
|
138
148
|
return [`task_get ${refs.task} with full=true`];
|
|
139
149
|
if (read.kind === "phase")
|
|
140
150
|
return [`phase_get ${refs.phase} with full=true`];
|
|
141
|
-
|
|
151
|
+
if (read.kind === "feature")
|
|
152
|
+
return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
|
|
153
|
+
return [];
|
|
142
154
|
}));
|
|
143
155
|
if (requirementReadRequired)
|
|
144
|
-
actions.push(
|
|
156
|
+
actions.push(`requirement_list with phaseRef=${refs.phase}`);
|
|
145
157
|
actions.push(retryAction);
|
|
146
158
|
return actions;
|
|
147
159
|
}
|
|
@@ -221,6 +233,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
221
233
|
{ value: "project language", label: "project language", description: "Set persistent language preferences" },
|
|
222
234
|
{ value: "project guidelines", label: "project guidelines", description: "Edit the canonical Project Guidelines section" },
|
|
223
235
|
{ value: "project migrate-context", label: "project migrate-context", description: "Preview and explicitly migrate legacy project rules and decisions" },
|
|
236
|
+
{ value: "idea list", label: "idea list", description: "List Ideas Inbox entries" },
|
|
237
|
+
{ value: "idea add", label: "idea add", description: "Create an Ideas Inbox entry" },
|
|
238
|
+
{ value: "idea show", label: "idea show", description: "Show an idea" },
|
|
239
|
+
{ value: "idea update", label: "idea update", description: "Update an idea" },
|
|
240
|
+
{ value: "idea delete", label: "idea delete", description: "Delete an idea after confirmation" },
|
|
241
|
+
{ value: "idea promote", label: "idea promote", description: "Begin guided idea promotion" },
|
|
224
242
|
{ value: "feature list", label: "feature list", description: "List features" },
|
|
225
243
|
{ value: "feature add", label: "feature add", description: "Create a feature" },
|
|
226
244
|
{ value: "feature show", label: "feature show", description: "Show a feature" },
|
|
@@ -329,7 +347,7 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
|
329
347
|
project.contentLanguage = contentLanguage;
|
|
330
348
|
project.chatLanguage = chatLanguage;
|
|
331
349
|
if (persist)
|
|
332
|
-
await st.
|
|
350
|
+
await st.updateProject((current) => ({ ...current, contentLanguage, chatLanguage }));
|
|
333
351
|
}
|
|
334
352
|
return project;
|
|
335
353
|
}
|
|
@@ -338,7 +356,11 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
|
338
356
|
project.contentLanguage = contentLanguage || fallback;
|
|
339
357
|
project.chatLanguage = chatLanguage || fallback;
|
|
340
358
|
if (persist)
|
|
341
|
-
await st.
|
|
359
|
+
await st.updateProject((current) => ({
|
|
360
|
+
...current,
|
|
361
|
+
contentLanguage: project.contentLanguage,
|
|
362
|
+
chatLanguage: project.chatLanguage,
|
|
363
|
+
}));
|
|
342
364
|
return project;
|
|
343
365
|
}
|
|
344
366
|
return project;
|
|
@@ -673,6 +695,33 @@ function statusIcon(status) {
|
|
|
673
695
|
function pad(n) {
|
|
674
696
|
return String(n).padStart(3, "0");
|
|
675
697
|
}
|
|
698
|
+
async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
|
|
699
|
+
if (targetType === "project") {
|
|
700
|
+
const project = await st.loadProject();
|
|
701
|
+
return { ok: true, owner: { kind: "project" }, ref: project.name };
|
|
702
|
+
}
|
|
703
|
+
const ref = targetRef?.trim();
|
|
704
|
+
if (!ref)
|
|
705
|
+
return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
|
|
706
|
+
const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
|
|
707
|
+
const features = featuresDoc.features;
|
|
708
|
+
if (targetType === "feature") {
|
|
709
|
+
const resolved = resolveFeatureRefStrict(features, ref);
|
|
710
|
+
if (!resolved.ok)
|
|
711
|
+
return { ok: false, error: resolved.error };
|
|
712
|
+
return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
|
|
713
|
+
}
|
|
714
|
+
if (targetType === "phase") {
|
|
715
|
+
const phase = findPhaseByRef(phases, features, ref);
|
|
716
|
+
if (!phase)
|
|
717
|
+
return { ok: false, error: `Phase not found: ${ref}` };
|
|
718
|
+
return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
|
|
719
|
+
}
|
|
720
|
+
const found = findTaskByRef(phases, features, ref);
|
|
721
|
+
if (!found)
|
|
722
|
+
return { ok: false, error: `Task not found: ${ref}` };
|
|
723
|
+
return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` };
|
|
724
|
+
}
|
|
676
725
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
677
726
|
const previousStatus = task.status;
|
|
678
727
|
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
@@ -705,8 +754,7 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
705
754
|
if (!(await isPortAvailable(explicitPort)) && project.webPort !== explicitPort) {
|
|
706
755
|
throw new Error(`Port ${explicitPort} is already in use`);
|
|
707
756
|
}
|
|
708
|
-
|
|
709
|
-
await st.saveProject(project);
|
|
757
|
+
await st.updateProject((current) => ({ ...current, webPort: explicitPort }));
|
|
710
758
|
return explicitPort;
|
|
711
759
|
}
|
|
712
760
|
// Reuse the persisted port if it's still free (stable port across restarts,
|
|
@@ -723,8 +771,7 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
723
771
|
continue;
|
|
724
772
|
if (await isPortAvailable(port)) {
|
|
725
773
|
if (project.webPort <= 0) {
|
|
726
|
-
|
|
727
|
-
await st.saveProject(project);
|
|
774
|
+
await st.updateProject((current) => ({ ...current, webPort: port }));
|
|
728
775
|
}
|
|
729
776
|
return port;
|
|
730
777
|
}
|
|
@@ -856,8 +903,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
856
903
|
// existing canonical). Never persist a transient fallback port — that
|
|
857
904
|
// hijacks the primary session's port when two instances share a .planner.
|
|
858
905
|
if (project.webPort <= 0 || project.webPort === realPort) {
|
|
859
|
-
|
|
860
|
-
await st.saveProject(project);
|
|
906
|
+
await st.updateProject((current) => ({ ...current, webPort: realPort }));
|
|
861
907
|
}
|
|
862
908
|
}
|
|
863
909
|
}
|
|
@@ -1239,11 +1285,16 @@ export default function planPiExtension(pi) {
|
|
|
1239
1285
|
return;
|
|
1240
1286
|
}
|
|
1241
1287
|
if (a === "version") {
|
|
1288
|
+
const packages = runtimePackagesDiagnostic([PI_ADAPTER_PACKAGE, CORE_PACKAGE, SERVER_PACKAGE]);
|
|
1289
|
+
const capabilities = runtimeCapabilities();
|
|
1242
1290
|
ctx.ui.notify([
|
|
1243
|
-
"Agent Plan runtime
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1291
|
+
"Agent Plan runtime diagnostics",
|
|
1292
|
+
"Runtime packages (loaded package manifests):",
|
|
1293
|
+
`- ${PI_ADAPTER_PACKAGE.name}: loaded ${packages[PI_ADAPTER_PACKAGE.name]?.loadedVersion ?? PI_ADAPTER_PACKAGE.version}`,
|
|
1294
|
+
`- ${CORE_PACKAGE.name}: loaded ${packages[CORE_PACKAGE.name]?.loadedVersion ?? CORE_PACKAGE.version}`,
|
|
1295
|
+
`- ${SERVER_PACKAGE.name}: loaded ${packages[SERVER_PACKAGE.name]?.loadedVersion ?? SERVER_PACKAGE.version}`,
|
|
1296
|
+
`Plan schema: manifest schemaVersion ${capabilities.planSchema.manifestSchemaVersion}`,
|
|
1297
|
+
`Allocation registry: v${capabilities.allocationRegistry.version}; supported kinds: ${capabilities.allocationRegistry.supportedKinds.join(", ")}`,
|
|
1247
1298
|
].join("\n"), "info");
|
|
1248
1299
|
return;
|
|
1249
1300
|
}
|
|
@@ -1310,12 +1361,18 @@ export default function planPiExtension(pi) {
|
|
|
1310
1361
|
// ── show ──
|
|
1311
1362
|
if (a === "show") {
|
|
1312
1363
|
const plan = await st.loadAll();
|
|
1364
|
+
const activeTasks = plan.phases.flatMap((phase) => phase.tasks
|
|
1365
|
+
.filter((task) => task.status === "in-progress")
|
|
1366
|
+
.map((task) => `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)} — ${task.title} (in-progress)`));
|
|
1313
1367
|
ctx.ui.notify([
|
|
1314
1368
|
`📋 **${plan.project.name}**`,
|
|
1315
1369
|
` Description: ${plan.project.description || "*not set*"}`,
|
|
1316
1370
|
` Goal: ${plan.project.goal || "*not set*"}`,
|
|
1317
1371
|
` Phases: ${plan.phases.length}`,
|
|
1318
1372
|
` Requirements: ${plan.requirements.requirements.length}`,
|
|
1373
|
+
...(activeTasks.length === 0
|
|
1374
|
+
? [" Active tasks: none (verified from all persisted phase task statuses)"]
|
|
1375
|
+
: [` Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT: multiple tasks are in progress" : ""}:`, ...activeTasks.map((task) => ` - ${task}`)]),
|
|
1319
1376
|
` Updated: ${plan.manifest.updatedAt}`,
|
|
1320
1377
|
].join("\n"), "info");
|
|
1321
1378
|
return;
|
|
@@ -1559,7 +1616,17 @@ export default function planPiExtension(pi) {
|
|
|
1559
1616
|
}))
|
|
1560
1617
|
: project.acceptedDecisions,
|
|
1561
1618
|
};
|
|
1562
|
-
await st.
|
|
1619
|
+
await st.updateProject((current) => ({
|
|
1620
|
+
...current,
|
|
1621
|
+
goal: draftProject.goal,
|
|
1622
|
+
technologies: draftProject.technologies,
|
|
1623
|
+
tools: draftProject.tools,
|
|
1624
|
+
globalRules: draftProject.globalRules,
|
|
1625
|
+
decisions: draftProject.decisions,
|
|
1626
|
+
acceptedDecisions: draftProject.acceptedDecisions,
|
|
1627
|
+
contentLanguage: draftProject.contentLanguage,
|
|
1628
|
+
chatLanguage: draftProject.chatLanguage,
|
|
1629
|
+
}));
|
|
1563
1630
|
await st.saveCodebaseProfile(profile);
|
|
1564
1631
|
await st.writeGenerated();
|
|
1565
1632
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
@@ -1585,7 +1652,11 @@ export default function planPiExtension(pi) {
|
|
|
1585
1652
|
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
|
|
1586
1653
|
project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
|
|
1587
1654
|
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
|
|
1588
|
-
await st.
|
|
1655
|
+
await st.updateProject((current) => ({
|
|
1656
|
+
...current,
|
|
1657
|
+
contentLanguage: project.contentLanguage,
|
|
1658
|
+
chatLanguage: project.chatLanguage,
|
|
1659
|
+
}));
|
|
1589
1660
|
await st.writeGenerated();
|
|
1590
1661
|
ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
|
|
1591
1662
|
return;
|
|
@@ -1593,12 +1664,12 @@ export default function planPiExtension(pi) {
|
|
|
1593
1664
|
if (b === "guidelines") {
|
|
1594
1665
|
const project = await st.loadProject();
|
|
1595
1666
|
const nextContent = await ctx.ui.editor("Project Guidelines / Coding Standard (Markdown or plain text). Agents must read this before task work.", project.projectGuidelines.content || "");
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1667
|
+
const guidelinesContent = nextContent?.trim() || "";
|
|
1668
|
+
project.projectGuidelines = { ...project.projectGuidelines, content: guidelinesContent };
|
|
1669
|
+
await st.updateProject((current) => ({
|
|
1670
|
+
...current,
|
|
1671
|
+
projectGuidelines: { ...current.projectGuidelines, content: guidelinesContent },
|
|
1672
|
+
}), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
|
|
1602
1673
|
await st.writeGenerated();
|
|
1603
1674
|
ctx.ui.notify(project.projectGuidelines.content ? "Project Guidelines updated." : "Project Guidelines cleared.", "info");
|
|
1604
1675
|
return;
|
|
@@ -1926,9 +1997,7 @@ export default function planPiExtension(pi) {
|
|
|
1926
1997
|
}
|
|
1927
1998
|
else {
|
|
1928
1999
|
for (const phase of phases) {
|
|
1929
|
-
phase.featureId
|
|
1930
|
-
phase.updatedAt = nowISO();
|
|
1931
|
-
await st.savePhase(phase);
|
|
2000
|
+
await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
|
|
1932
2001
|
affectedPhases += 1;
|
|
1933
2002
|
}
|
|
1934
2003
|
}
|
|
@@ -1968,7 +2037,7 @@ export default function planPiExtension(pi) {
|
|
|
1968
2037
|
// and only then abandoning the follow-up question.
|
|
1969
2038
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1970
2039
|
let phase;
|
|
1971
|
-
await withFeatureLock(feature.id, async () => {
|
|
2040
|
+
await st.runBatch(() => withFeatureLock(feature.id, async () => {
|
|
1972
2041
|
const phases = await st.loadAllPhases();
|
|
1973
2042
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1974
2043
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
@@ -1996,7 +2065,7 @@ export default function planPiExtension(pi) {
|
|
|
1996
2065
|
return doc;
|
|
1997
2066
|
});
|
|
1998
2067
|
await st.writeGenerated();
|
|
1999
|
-
});
|
|
2068
|
+
}));
|
|
2000
2069
|
if (!phase)
|
|
2001
2070
|
return;
|
|
2002
2071
|
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
@@ -2044,8 +2113,12 @@ export default function planPiExtension(pi) {
|
|
|
2044
2113
|
ctx.ui.notify(recapLines.join("\n"), "info");
|
|
2045
2114
|
const changeNote = await ctx.ui.input("Anything changed? (free text, or leave empty to keep current plan)");
|
|
2046
2115
|
if (changeNote?.trim()) {
|
|
2047
|
-
|
|
2048
|
-
await st.
|
|
2116
|
+
const note = changeNote.trim();
|
|
2117
|
+
phase = await st.updatePhase(phase.id, (current) => ({
|
|
2118
|
+
...current,
|
|
2119
|
+
notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
|
|
2120
|
+
updatedAt: nowISO(),
|
|
2121
|
+
}));
|
|
2049
2122
|
if (capturedPi) {
|
|
2050
2123
|
await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
|
|
2051
2124
|
"Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
|
|
@@ -2067,25 +2140,22 @@ export default function planPiExtension(pi) {
|
|
|
2067
2140
|
if (ans?.trim())
|
|
2068
2141
|
answers[q.key] = ans.trim();
|
|
2069
2142
|
}
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
phase
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
phase.status = "planned";
|
|
2085
|
-
phase.updatedAt = nowISO();
|
|
2086
|
-
await st.savePhase(phase);
|
|
2143
|
+
phase = await st.updatePhase(phase.id, (current) => ({
|
|
2144
|
+
...current,
|
|
2145
|
+
...(answers.goal ? { goals: [answers.goal] } : {}),
|
|
2146
|
+
...(answers.summary ? { summary: answers.summary } : {}),
|
|
2147
|
+
...(answers.scope ? { description: answers.scope } : {}),
|
|
2148
|
+
...(answers.non_scope ? { nonGoals: [answers.non_scope] } : {}),
|
|
2149
|
+
...(answers.deps ? { dependencies: splitCsv(answers.deps) } : {}),
|
|
2150
|
+
...(answers.risks ? { risks: splitCsv(answers.risks) } : {}),
|
|
2151
|
+
...(answers.completion ? { completionCriteria: splitCsv(answers.completion) } : {}),
|
|
2152
|
+
discussedAt: nowISO(),
|
|
2153
|
+
contextReady: true,
|
|
2154
|
+
contextReadyReason: "Updated through /planner phase discuss.",
|
|
2155
|
+
updatedAt: nowISO(),
|
|
2156
|
+
}));
|
|
2087
2157
|
await st.writeGenerated();
|
|
2088
|
-
ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **
|
|
2158
|
+
ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **context ready**; derived status remains ${phase.status}.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
2089
2159
|
return;
|
|
2090
2160
|
}
|
|
2091
2161
|
if (b === "show") {
|
|
@@ -2157,33 +2227,25 @@ export default function planPiExtension(pi) {
|
|
|
2157
2227
|
const deps = await ctx.ui.input(`Dependencies (comma-separated) [${phase.dependencies.join(", ") || ""}]`);
|
|
2158
2228
|
const risks = await ctx.ui.input(`Risks (comma-separated) [${phase.risks.join(", ") || ""}]`);
|
|
2159
2229
|
const completion = await ctx.ui.input(`Completion criteria (comma-separated) [${phase.completionCriteria.join(", ") || ""}]`);
|
|
2160
|
-
// Update only changed fields
|
|
2161
|
-
if (title?.trim())
|
|
2162
|
-
phase.title = title.trim();
|
|
2163
2230
|
if (statusInput?.trim()) {
|
|
2164
2231
|
const valid = ["draft", "discovery", "planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
2165
2232
|
if (!valid.includes(statusInput.trim())) {
|
|
2166
2233
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2167
2234
|
return;
|
|
2168
2235
|
}
|
|
2169
|
-
phase.status = statusInput.trim();
|
|
2170
2236
|
}
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
if (completion?.trim())
|
|
2184
|
-
phase.completionCriteria = completion.split(",").map((g) => g.trim()).filter(Boolean);
|
|
2185
|
-
phase.updatedAt = nowISO();
|
|
2186
|
-
await st.savePhase(phase);
|
|
2237
|
+
phase = await st.updatePhase(phase.id, (current) => ({
|
|
2238
|
+
...current,
|
|
2239
|
+
...(title?.trim() ? { title: title.trim(), slug: normalizeSlug(title) } : {}),
|
|
2240
|
+
...(summary !== undefined ? { summary } : {}),
|
|
2241
|
+
...(desc?.trim() ? { description: desc.trim() } : {}),
|
|
2242
|
+
...(goals?.trim() ? { goals: splitCsv(goals) } : {}),
|
|
2243
|
+
...(nonGoals?.trim() ? { nonGoals: splitCsv(nonGoals) } : {}),
|
|
2244
|
+
...(deps?.trim() ? { dependencies: splitCsv(deps) } : {}),
|
|
2245
|
+
...(risks?.trim() ? { risks: splitCsv(risks) } : {}),
|
|
2246
|
+
...(completion?.trim() ? { completionCriteria: splitCsv(completion) } : {}),
|
|
2247
|
+
updatedAt: nowISO(),
|
|
2248
|
+
}));
|
|
2187
2249
|
await st.writeGenerated();
|
|
2188
2250
|
ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
2189
2251
|
return;
|
|
@@ -2232,12 +2294,15 @@ export default function planPiExtension(pi) {
|
|
|
2232
2294
|
dependsOn: [],
|
|
2233
2295
|
pauseSnapshot: null, pauseHistory: [],
|
|
2234
2296
|
startedAt: "", completedAt: "",
|
|
2297
|
+
activeOwnerSession: "",
|
|
2235
2298
|
createdAt: now, updatedAt: now,
|
|
2236
2299
|
};
|
|
2237
|
-
phase.
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2300
|
+
await st.updatePhase(phase.id, (current) => ({
|
|
2301
|
+
...current,
|
|
2302
|
+
tasks: [...current.tasks, task],
|
|
2303
|
+
taskIds: [...current.taskIds, taskId],
|
|
2304
|
+
updatedAt: nowISO(),
|
|
2305
|
+
}));
|
|
2241
2306
|
await st.writeGenerated();
|
|
2242
2307
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
2243
2308
|
if (isYes(startDiscuss)) {
|
|
@@ -2268,18 +2333,20 @@ export default function planPiExtension(pi) {
|
|
|
2268
2333
|
const { phase, task } = resolved;
|
|
2269
2334
|
const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
|
|
2270
2335
|
const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
|
|
2336
|
+
const checklist = checklistSeed?.trim()
|
|
2337
|
+
? splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
2338
|
+
number: index + 1,
|
|
2275
2339
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
2276
2340
|
title: itemTitle,
|
|
2277
2341
|
checked: false,
|
|
2278
|
-
}))
|
|
2279
|
-
|
|
2280
|
-
task.
|
|
2281
|
-
|
|
2282
|
-
|
|
2342
|
+
}))
|
|
2343
|
+
: undefined;
|
|
2344
|
+
await st.updateTask(phase.id, task.id, (current) => ({
|
|
2345
|
+
...current,
|
|
2346
|
+
...(description?.trim() ? { description: description.trim() } : {}),
|
|
2347
|
+
...(checklist ? { checklist } : {}),
|
|
2348
|
+
updatedAt: nowISO(),
|
|
2349
|
+
}));
|
|
2283
2350
|
await st.writeGenerated();
|
|
2284
2351
|
ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
|
|
2285
2352
|
return;
|
|
@@ -2345,10 +2412,12 @@ export default function planPiExtension(pi) {
|
|
|
2345
2412
|
ctx.ui.notify("Aborted", "warning");
|
|
2346
2413
|
return;
|
|
2347
2414
|
}
|
|
2348
|
-
phase.
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2415
|
+
await st.updatePhase(phase.id, (current) => ({
|
|
2416
|
+
...current,
|
|
2417
|
+
tasks: current.tasks.filter((candidate) => candidate.id !== task.id),
|
|
2418
|
+
taskIds: current.taskIds.filter((id) => id !== task.id),
|
|
2419
|
+
updatedAt: nowISO(),
|
|
2420
|
+
}));
|
|
2352
2421
|
await st.writeGenerated();
|
|
2353
2422
|
ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2354
2423
|
return;
|
|
@@ -2377,133 +2446,137 @@ export default function planPiExtension(pi) {
|
|
|
2377
2446
|
const title = await ctx.ui.input(`Title [${task.title}]`);
|
|
2378
2447
|
const statusInput = await ctx.ui.input(`Status [${task.status}] (planned|in-progress|done|blocked|canceled)`);
|
|
2379
2448
|
const desc = await ctx.ui.input(`Description [leave empty to keep]`);
|
|
2380
|
-
// Update only changed fields
|
|
2381
|
-
if (title?.trim())
|
|
2382
|
-
task.title = title.trim();
|
|
2383
2449
|
const now = nowISO();
|
|
2384
|
-
|
|
2385
|
-
|
|
2450
|
+
const normalizedStatus = statusInput?.trim();
|
|
2451
|
+
if (normalizedStatus) {
|
|
2386
2452
|
const valid = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
2387
2453
|
if (!valid.includes(normalizedStatus)) {
|
|
2388
2454
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2389
2455
|
return;
|
|
2390
2456
|
}
|
|
2391
2457
|
if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
|
|
2392
|
-
ctx.ui.notify("Task start/resume transitions require /planner task start so
|
|
2458
|
+
ctx.ui.notify("Task start/resume transitions require /planner task start so lifecycle state remains consistent.", "warning");
|
|
2393
2459
|
return;
|
|
2394
2460
|
}
|
|
2395
2461
|
if (normalizedStatus === "done" && normalizedStatus !== task.status) {
|
|
2396
2462
|
ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
|
|
2397
2463
|
return;
|
|
2398
2464
|
}
|
|
2399
|
-
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2400
2465
|
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2466
|
+
const result = await st.updateTask(phase.id, task.id, (current) => {
|
|
2467
|
+
const next = {
|
|
2468
|
+
...current,
|
|
2469
|
+
...(title?.trim() ? { title: title.trim() } : {}),
|
|
2470
|
+
...(normalizedStatus ? { status: normalizedStatus } : {}),
|
|
2471
|
+
...(desc?.trim() ? { description: desc.trim() } : {}),
|
|
2472
|
+
updatedAt: now,
|
|
2473
|
+
};
|
|
2474
|
+
if (normalizedStatus)
|
|
2475
|
+
applyTaskLifecycleDates(next, normalizedStatus, now);
|
|
2476
|
+
return next;
|
|
2477
|
+
});
|
|
2406
2478
|
await st.syncTaskStatusRollup(phase.id);
|
|
2407
2479
|
await st.writeGenerated();
|
|
2408
|
-
ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2480
|
+
ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (${result.task.status})${result.task.shortId ? ` · ${result.task.shortId}` : ""}`, "info");
|
|
2409
2481
|
return;
|
|
2410
2482
|
}
|
|
2411
2483
|
if (b === "start") {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
if (!task) {
|
|
2420
|
-
phase = await pickPhase();
|
|
2421
|
-
if (!phase) {
|
|
2422
|
-
ctx.ui.notify("No phases available", "warning");
|
|
2423
|
-
return;
|
|
2424
|
-
}
|
|
2425
|
-
if (phase.tasks.length === 0) {
|
|
2426
|
-
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2427
|
-
return;
|
|
2484
|
+
return st.runBatch(async () => {
|
|
2485
|
+
let phase = null;
|
|
2486
|
+
let task = null;
|
|
2487
|
+
if (subArgs.trim()) {
|
|
2488
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
2489
|
+
phase = resolved?.phase ?? null;
|
|
2490
|
+
task = resolved?.task ?? null;
|
|
2428
2491
|
}
|
|
2429
|
-
task = await pickTask(phase);
|
|
2430
2492
|
if (!task) {
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
taskId: task.id,
|
|
2446
|
-
phaseId: phase.id,
|
|
2447
|
-
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
2448
|
-
task,
|
|
2449
|
-
phase,
|
|
2450
|
-
...(feature ? { feature } : {}),
|
|
2451
|
-
requirements: linkedRequirements,
|
|
2452
|
-
requirementIds: linkedRequirementIds,
|
|
2453
|
-
};
|
|
2454
|
-
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2455
|
-
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2456
|
-
const project = await st.loadProject();
|
|
2457
|
-
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
2458
|
-
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
2459
|
-
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
2460
|
-
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
2461
|
-
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
2462
|
-
if (!projectGuidelinesReady) {
|
|
2463
|
-
const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `/planner task start ${taskRef}`);
|
|
2464
|
-
ctx.ui.notify(`❌ TASK START FAILED [PROJECT_GUIDELINES_READ_REQUIRED]\nstarted: false\nProject Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
|
|
2465
|
-
return;
|
|
2493
|
+
phase = await pickPhase();
|
|
2494
|
+
if (!phase) {
|
|
2495
|
+
ctx.ui.notify("No phases available", "warning");
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
if (phase.tasks.length === 0) {
|
|
2499
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
task = await pickTask(phase);
|
|
2503
|
+
if (!task) {
|
|
2504
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2466
2507
|
}
|
|
2467
|
-
if (
|
|
2468
|
-
const
|
|
2469
|
-
|
|
2470
|
-
|
|
2508
|
+
if (phase) {
|
|
2509
|
+
const features = (await st.loadFeatures()).features;
|
|
2510
|
+
const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
|
|
2511
|
+
const linkedRequirements = [
|
|
2512
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2513
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2514
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2515
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2516
|
+
const contextInput = {
|
|
2517
|
+
sessionId: plannerSessionId,
|
|
2518
|
+
taskId: task.id,
|
|
2519
|
+
phaseId: phase.id,
|
|
2520
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
2521
|
+
task,
|
|
2522
|
+
phase,
|
|
2523
|
+
...(feature ? { feature } : {}),
|
|
2524
|
+
requirements: linkedRequirements,
|
|
2525
|
+
requirementIds: linkedRequirementIds,
|
|
2526
|
+
};
|
|
2527
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2528
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2529
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
2530
|
+
const project = await st.loadProject();
|
|
2531
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
2532
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
2533
|
+
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
2534
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
2535
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
2536
|
+
if (!projectGuidelinesReady) {
|
|
2537
|
+
const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `/planner task start ${taskRef}`);
|
|
2538
|
+
ctx.ui.notify(`❌ TASK START FAILED [PROJECT_GUIDELINES_READ_REQUIRED]\nstarted: false\nProject Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
if (!contextEligibility.eligible) {
|
|
2542
|
+
const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `/planner task start ${taskRef}`);
|
|
2543
|
+
ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
if (!requirementsReady) {
|
|
2547
|
+
ctx.ui.notify(`❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\n${requirementEligibility.reason}\nRun requirement_list with phaseRef=${phaseRef}, then retry /planner task start. The task remains unchanged.`, "error");
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2551
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2552
|
+
}
|
|
2471
2553
|
}
|
|
2472
|
-
if (
|
|
2473
|
-
ctx.ui.notify("
|
|
2554
|
+
if (task.status === "in-progress") {
|
|
2555
|
+
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2474
2556
|
return;
|
|
2475
2557
|
}
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2558
|
+
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2559
|
+
// It is archived only when the phase completes (auto) or the user
|
|
2560
|
+
// explicitly clears it.
|
|
2561
|
+
const now = nowISO();
|
|
2562
|
+
const result = await st.updateTask(phase.id, task.id, (current) => {
|
|
2563
|
+
const entry = {
|
|
2564
|
+
id: createChecklistItemId(current.id, current.statusLog.length + 1, `${current.status}-in-progress`),
|
|
2565
|
+
date: now,
|
|
2566
|
+
fromStatus: current.status,
|
|
2567
|
+
toStatus: "in-progress",
|
|
2568
|
+
title: current.status === "done" ? "Reopened" : "→ in-progress",
|
|
2569
|
+
description: current.status === "done" ? "Task reopened from done status." : "",
|
|
2570
|
+
};
|
|
2571
|
+
const next = { ...current, status: "in-progress", statusLog: [...current.statusLog, entry], updatedAt: now };
|
|
2572
|
+
applyTaskLifecycleDates(next, "in-progress", now);
|
|
2573
|
+
return next;
|
|
2574
|
+
});
|
|
2575
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
2576
|
+
await st.writeGenerated();
|
|
2577
|
+
ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (in-progress)${result.task.shortId ? ` · ${result.task.shortId}` : ""}`, "info");
|
|
2482
2578
|
return;
|
|
2483
|
-
}
|
|
2484
|
-
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2485
|
-
// It is archived only when the phase completes (auto) or the user
|
|
2486
|
-
// explicitly clears it.
|
|
2487
|
-
const now = nowISO();
|
|
2488
|
-
// Record status change in the incremental statusLog.
|
|
2489
|
-
const prevStatus = task.status;
|
|
2490
|
-
const entry = {
|
|
2491
|
-
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
|
|
2492
|
-
date: now,
|
|
2493
|
-
fromStatus: task.status,
|
|
2494
|
-
toStatus: "in-progress",
|
|
2495
|
-
title: task.status === "done" ? "Reopened" : `→ in-progress`,
|
|
2496
|
-
description: task.status === "done" ? "Task reopened from done status." : "",
|
|
2497
|
-
};
|
|
2498
|
-
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2499
|
-
applyTaskLifecycleDates(task, "in-progress", now);
|
|
2500
|
-
task.updatedAt = now;
|
|
2501
|
-
phase.updatedAt = now;
|
|
2502
|
-
await st.savePhase(phase);
|
|
2503
|
-
await st.syncTaskStatusRollup(phase.id);
|
|
2504
|
-
await st.writeGenerated();
|
|
2505
|
-
ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2506
|
-
return;
|
|
2579
|
+
});
|
|
2507
2580
|
}
|
|
2508
2581
|
if (b === "complete") {
|
|
2509
2582
|
let phase = null;
|
|
@@ -2547,26 +2620,30 @@ export default function planPiExtension(pi) {
|
|
|
2547
2620
|
return;
|
|
2548
2621
|
}
|
|
2549
2622
|
const now = nowISO();
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2623
|
+
const result = await st.updateTask(phase.id, task.id, (current) => {
|
|
2624
|
+
const entry = {
|
|
2625
|
+
id: createChecklistItemId(current.id, current.statusLog.length + 1, `${current.status}-done`),
|
|
2626
|
+
date: now,
|
|
2627
|
+
fromStatus: current.status,
|
|
2628
|
+
toStatus: "done",
|
|
2629
|
+
title: "→ done",
|
|
2630
|
+
description: completionSummary.trim(),
|
|
2631
|
+
};
|
|
2632
|
+
const separator = current.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2633
|
+
const next = {
|
|
2634
|
+
...current,
|
|
2635
|
+
status: "done",
|
|
2636
|
+
statusLog: [...current.statusLog, entry],
|
|
2637
|
+
description: `${current.description}${separator}${completionSummary.trim()}`,
|
|
2638
|
+
descriptionUpdatedAt: now,
|
|
2639
|
+
updatedAt: now,
|
|
2640
|
+
};
|
|
2641
|
+
applyTaskLifecycleDates(next, "done", now);
|
|
2642
|
+
return next;
|
|
2643
|
+
});
|
|
2567
2644
|
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2568
2645
|
await st.writeGenerated();
|
|
2569
|
-
ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
|
|
2646
|
+
ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (done)${result.task.shortId ? ` · ${result.task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
|
|
2570
2647
|
return;
|
|
2571
2648
|
}
|
|
2572
2649
|
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
@@ -2672,8 +2749,15 @@ export default function planPiExtension(pi) {
|
|
|
2672
2749
|
ctx.ui.notify(r.error, "warning");
|
|
2673
2750
|
return;
|
|
2674
2751
|
}
|
|
2675
|
-
const
|
|
2676
|
-
|
|
2752
|
+
const phase = await st.loadPhase(r.phase.id);
|
|
2753
|
+
const content = phase.handoff;
|
|
2754
|
+
const contentHash = content.trim() ? handoffContentHash(content) : "";
|
|
2755
|
+
const resumeReady = Boolean(phase.handoffAudit?.resumeReadyAt)
|
|
2756
|
+
&& phase.handoffAudit?.contentHash === contentHash
|
|
2757
|
+
&& phase.handoffAudit?.contentLength === content.length;
|
|
2758
|
+
ctx.ui.notify(content.trim()
|
|
2759
|
+
? `Handoff ${r.compositeRef} (${resumeReady ? "resume-ready" : "verification required"}; contentHash ${contentHash}):\n${content}`
|
|
2760
|
+
: `No handoff set on ${r.compositeRef}.`, "info");
|
|
2677
2761
|
return;
|
|
2678
2762
|
}
|
|
2679
2763
|
if (action === "write") {
|
|
@@ -2688,14 +2772,16 @@ export default function planPiExtension(pi) {
|
|
|
2688
2772
|
await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
|
|
2689
2773
|
"If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
|
|
2690
2774
|
"Before writing, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
|
|
2691
|
-
"After confirmation, call `handoff_prepare` with that exact phaseRef. Read the entire existing handoff and its handoffUpdatedAt token.
|
|
2775
|
+
"After confirmation, call `handoff_prepare` with that exact phaseRef. BEFORE drafting prose, use its exact returned scaffold and cold-start inventory categories. Read the entire existing handoff and its handoffUpdatedAt token. Complete every source review by checking the conversation and approvals, planner entities and sibling tasks, current diff/implementation, verification/runtime evidence, and peer-agent or network messages (or explicitly record that no peer output exists). Inventory every exact file, symbol, working-tree ownership/completion state, commit authorization, work or deletion not yet started, command/tool path, runtime call site, preservation constraint, proof observation, related planned capability, user-visible behavior, operator action, blocker/risk, remaining item, and ordered resume step known from this session. Then call `handoff_write` once with the exact token, the completed scaffold, the structured coldStartInventory, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Every inventory item must appear verbatim in the inline handoff or a validated supporting document. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps. The write persists only a candidate and returns resumeReady=false. Immediately call `handoff_show`, read the entire persisted body, compare it again against all five sources, and either rewrite every discovered omission or call `handoff_verify` with the shown contentHash. Never tell the user the handoff is complete until verification returns resumeReady=true.\n\n" +
|
|
2692
2776
|
"The reconciled handoff MUST contain at minimum:\n" +
|
|
2693
2777
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2694
2778
|
"- `Reason:` why this handoff is being written\n" +
|
|
2695
2779
|
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2696
|
-
"- `##
|
|
2780
|
+
"- `## Current and partial state`: concise completed/current/partial state (extended detail goes in .planner/docs/)\n" +
|
|
2781
|
+
"- `## Preservation constraints`: minimal surviving behaviors and boundaries\n" +
|
|
2782
|
+
"- `## Supporting documents`: ordered .planner/docs/*.md links or an explicit verified none-needed statement\n" +
|
|
2783
|
+
"- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
|
|
2697
2784
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2698
|
-
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2699
2785
|
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoff refresh synchronizes durable task/phase/feature context and updates the single active phase.handoff; `.planner/HANDOFF.md` is deprecated.");
|
|
2700
2786
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2701
2787
|
return;
|
|
@@ -2953,10 +3039,14 @@ export default function planPiExtension(pi) {
|
|
|
2953
3039
|
project.contentLanguage = normalizedContent || fallback;
|
|
2954
3040
|
project.chatLanguage = normalizedChat || fallback;
|
|
2955
3041
|
}
|
|
2956
|
-
await st.
|
|
3042
|
+
const persisted = await st.updateProject((current) => ({
|
|
3043
|
+
...current,
|
|
3044
|
+
contentLanguage: project.contentLanguage,
|
|
3045
|
+
chatLanguage: project.chatLanguage,
|
|
3046
|
+
}));
|
|
2957
3047
|
await st.writeGenerated();
|
|
2958
3048
|
return {
|
|
2959
|
-
content: [{ type: "text", text: `Saved language preferences: content=${
|
|
3049
|
+
content: [{ type: "text", text: `Saved language preferences: content=${persisted.contentLanguage || "(unset)"}, chat=${persisted.chatLanguage || "(unset)"}` }],
|
|
2960
3050
|
details: { contentLanguage: project.contentLanguage, chatLanguage: project.chatLanguage },
|
|
2961
3051
|
};
|
|
2962
3052
|
},
|
|
@@ -2969,7 +3059,7 @@ export default function planPiExtension(pi) {
|
|
|
2969
3059
|
projectName: Type.String({ description: "Concise project name/title" }),
|
|
2970
3060
|
description: Type.Optional(Type.String({ description: "Short project description" })),
|
|
2971
3061
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
2972
|
-
requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial
|
|
3062
|
+
requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial declarative product outcomes to seed as Requirements. Never include coding standards, best practices, formatting, verification process, or other Project Guidelines." })),
|
|
2973
3063
|
}),
|
|
2974
3064
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2975
3065
|
const st = loadStore(ctx);
|
|
@@ -3000,7 +3090,6 @@ export default function planPiExtension(pi) {
|
|
|
3000
3090
|
id: createRequirementId(),
|
|
3001
3091
|
title,
|
|
3002
3092
|
description: "",
|
|
3003
|
-
status: "planned",
|
|
3004
3093
|
macroTasks: [],
|
|
3005
3094
|
linkedPhaseIds: [],
|
|
3006
3095
|
sessionInfo: [],
|
|
@@ -3079,9 +3168,30 @@ export default function planPiExtension(pi) {
|
|
|
3079
3168
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
3080
3169
|
if (params.decisions !== undefined)
|
|
3081
3170
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
3082
|
-
await st.
|
|
3171
|
+
const persisted = await st.updateProject((current) => {
|
|
3172
|
+
const next = {
|
|
3173
|
+
...current,
|
|
3174
|
+
...(params.name !== undefined ? { name: params.name.trim() } : {}),
|
|
3175
|
+
...(params.description !== undefined ? { description: params.description.trim() } : {}),
|
|
3176
|
+
...(params.goal !== undefined ? { goal: params.goal.trim() } : {}),
|
|
3177
|
+
...(params.scope !== undefined ? { scope: params.scope.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3178
|
+
...(params.outOfScope !== undefined ? { outOfScope: params.outOfScope.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3179
|
+
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3180
|
+
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3181
|
+
...(params.globalRules !== undefined ? { globalRules: params.globalRules.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3182
|
+
...(params.decisions !== undefined ? { decisions: params.decisions.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3183
|
+
};
|
|
3184
|
+
if (params.descriptionRef !== undefined) {
|
|
3185
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
3186
|
+
if (descriptionRef)
|
|
3187
|
+
next.descriptionRef = descriptionRef;
|
|
3188
|
+
else
|
|
3189
|
+
delete next.descriptionRef;
|
|
3190
|
+
}
|
|
3191
|
+
return next;
|
|
3192
|
+
});
|
|
3083
3193
|
await st.writeGenerated();
|
|
3084
|
-
return { content: [{ type: "text", text: `Project updated: ${
|
|
3194
|
+
return { content: [{ type: "text", text: `Project updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...persisted, updated: true, updatedFields: receivedFields } };
|
|
3085
3195
|
},
|
|
3086
3196
|
});
|
|
3087
3197
|
pi.registerTool({
|
|
@@ -3126,12 +3236,10 @@ export default function planPiExtension(pi) {
|
|
|
3126
3236
|
if (!st)
|
|
3127
3237
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
3128
3238
|
const project = await st.loadProject();
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
};
|
|
3134
|
-
await st.saveProject(project);
|
|
3239
|
+
await st.updateProject((current) => ({
|
|
3240
|
+
...current,
|
|
3241
|
+
projectGuidelines: { ...current.projectGuidelines, content: params.content.trim() },
|
|
3242
|
+
}), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
|
|
3135
3243
|
await st.writeGenerated();
|
|
3136
3244
|
const refreshed = await st.loadProject();
|
|
3137
3245
|
return {
|
|
@@ -3175,28 +3283,57 @@ export default function planPiExtension(pi) {
|
|
|
3175
3283
|
pi.registerTool({
|
|
3176
3284
|
name: "requirement_list",
|
|
3177
3285
|
label: "Requirement List",
|
|
3178
|
-
description: "List
|
|
3179
|
-
parameters: Type.Object({
|
|
3180
|
-
|
|
3286
|
+
description: "List declarative product Requirements: user, business, or system outcomes. Requirements never contain coding standards, best practices, formatting, verification process, or other Project Guidelines. Pass phaseRef to deliver and attest only the Requirements linked to that phase or its owning feature; an unscoped inventory does not satisfy task-start read gates.",
|
|
3287
|
+
parameters: Type.Object({
|
|
3288
|
+
phaseRef: Type.Optional(Type.String({ description: "Optional target phase ref. When supplied, return full target-relevant requirements and record only those delivered requirements as read." })),
|
|
3289
|
+
}),
|
|
3290
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3181
3291
|
const st = await requirePlan(ctx);
|
|
3292
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
3182
3293
|
if (!st)
|
|
3183
3294
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3184
3295
|
const requirements = await st.loadRequirements();
|
|
3185
|
-
|
|
3296
|
+
if (!params.phaseRef?.trim()) {
|
|
3297
|
+
return {
|
|
3298
|
+
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements" }],
|
|
3299
|
+
details: { ...requirements, attestedRequirementIds: [], readScope: "inventory" },
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
const features = (await st.loadFeatures()).features;
|
|
3303
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseRef.trim());
|
|
3304
|
+
if (!phase)
|
|
3305
|
+
return { isError: true, content: [{ type: "text", text: `Phase not found: ${params.phaseRef}` }], details: { attestedRequirementIds: [] } };
|
|
3306
|
+
const delivered = [
|
|
3307
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
3308
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
3309
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
3310
|
+
delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
|
|
3311
|
+
const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
3312
|
+
const body = delivered.map((requirement) => [
|
|
3313
|
+
`- ${requirement.id} — ${requirement.title}`,
|
|
3314
|
+
` Description: ${requirement.description || "None"}`,
|
|
3315
|
+
` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
|
|
3316
|
+
` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
|
|
3317
|
+
].join("\n")).join("\n");
|
|
3186
3318
|
return {
|
|
3187
|
-
content: [{ type: "text", text:
|
|
3188
|
-
details:
|
|
3319
|
+
content: [{ type: "text", text: body || `No requirements linked to ${targetRef}` }],
|
|
3320
|
+
details: {
|
|
3321
|
+
...requirements,
|
|
3322
|
+
requirements: delivered,
|
|
3323
|
+
attestedRequirementIds: delivered.map((requirement) => requirement.id),
|
|
3324
|
+
readScope: "target",
|
|
3325
|
+
target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
|
|
3326
|
+
},
|
|
3189
3327
|
};
|
|
3190
3328
|
},
|
|
3191
3329
|
});
|
|
3192
3330
|
pi.registerTool({
|
|
3193
3331
|
name: "requirement_create",
|
|
3194
3332
|
label: "Requirement Create",
|
|
3195
|
-
description: "Create a
|
|
3333
|
+
description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use project_guidelines_update instead. Requirements have no lifecycle status.",
|
|
3196
3334
|
parameters: Type.Object({
|
|
3197
3335
|
title: Type.String({ description: "Requirement title" }),
|
|
3198
3336
|
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
3199
|
-
status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
|
|
3200
3337
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
|
|
3201
3338
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3202
3339
|
title: Type.String({ description: "Macro-task title" }),
|
|
@@ -3214,7 +3351,6 @@ export default function planPiExtension(pi) {
|
|
|
3214
3351
|
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
3215
3352
|
if (!links.ok)
|
|
3216
3353
|
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
3217
|
-
const requirements = await st.loadRequirements();
|
|
3218
3354
|
const now = nowISO();
|
|
3219
3355
|
let macroTasks;
|
|
3220
3356
|
try {
|
|
@@ -3232,34 +3368,31 @@ export default function planPiExtension(pi) {
|
|
|
3232
3368
|
id: createRequirementId(),
|
|
3233
3369
|
title,
|
|
3234
3370
|
description: params.description?.trim() ?? "",
|
|
3235
|
-
status: params.status?.trim() ?? "planned",
|
|
3236
3371
|
macroTasks,
|
|
3237
3372
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
3238
3373
|
sessionInfo: [],
|
|
3239
3374
|
createdAt: now,
|
|
3240
3375
|
updatedAt: now,
|
|
3241
3376
|
};
|
|
3242
|
-
requirements.requirements.push(requirement);
|
|
3243
3377
|
try {
|
|
3244
|
-
await st.
|
|
3378
|
+
await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
|
|
3245
3379
|
}
|
|
3246
3380
|
catch (error) {
|
|
3247
3381
|
const message = error instanceof RequirementMacroTaskError ? error.message : error instanceof Error ? error.message : "Invalid requirement.";
|
|
3248
3382
|
return { content: [{ type: "text", text: message }], details: {} };
|
|
3249
3383
|
}
|
|
3250
3384
|
await st.writeGenerated();
|
|
3251
|
-
return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: requirement };
|
|
3385
|
+
return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: { requirement, created: true } };
|
|
3252
3386
|
},
|
|
3253
3387
|
});
|
|
3254
3388
|
pi.registerTool({
|
|
3255
3389
|
name: "requirement_update",
|
|
3256
3390
|
label: "Requirement Update",
|
|
3257
|
-
description: "Update
|
|
3391
|
+
description: "Update a Requirement’s product outcome, linked phases, or ordered macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use project_guidelines_update instead. Requirements have no lifecycle status.",
|
|
3258
3392
|
parameters: Type.Object({
|
|
3259
3393
|
requirementId: Type.String({ description: "Requirement ID" }),
|
|
3260
3394
|
title: Type.Optional(Type.String({ description: "Requirement title" })),
|
|
3261
3395
|
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
3262
|
-
status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
|
|
3263
3396
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
|
|
3264
3397
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3265
3398
|
id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
|
|
@@ -3276,41 +3409,46 @@ export default function planPiExtension(pi) {
|
|
|
3276
3409
|
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
3277
3410
|
if (!requirement)
|
|
3278
3411
|
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
3279
|
-
if (params.title !== undefined) {
|
|
3280
|
-
|
|
3281
|
-
if (!title)
|
|
3282
|
-
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3283
|
-
requirement.title = title;
|
|
3412
|
+
if (params.title !== undefined && !params.title.trim()) {
|
|
3413
|
+
return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
|
|
3284
3414
|
}
|
|
3285
|
-
if (params.description
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
requirement.linkedPhaseIds = links.linkedPhaseIds;
|
|
3415
|
+
if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
|
|
3416
|
+
return mutationNoFieldsFailure({
|
|
3417
|
+
entity: "requirement",
|
|
3418
|
+
ref: params.requirementId,
|
|
3419
|
+
operation: "update",
|
|
3420
|
+
mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
|
|
3421
|
+
retryCommand: `requirement_update ${params.requirementId}`,
|
|
3422
|
+
});
|
|
3294
3423
|
}
|
|
3424
|
+
const links = params.linkedPhaseIds === undefined
|
|
3425
|
+
? undefined
|
|
3426
|
+
: await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
3427
|
+
if (links && !links.ok)
|
|
3428
|
+
return { content: [{ type: "text", text: links.error }], details: { errorCode: "REQUIREMENT_PHASE_LINK_INVALID", updated: false } };
|
|
3295
3429
|
const now = nowISO();
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3430
|
+
let persisted;
|
|
3431
|
+
try {
|
|
3432
|
+
persisted = await st.updateRequirement(requirement.id, (canonical) => ({
|
|
3433
|
+
...canonical,
|
|
3434
|
+
...(params.title !== undefined ? { title: params.title.trim() } : {}),
|
|
3435
|
+
...(params.description !== undefined ? { description: params.description.trim() } : {}),
|
|
3436
|
+
...(links?.ok ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
|
|
3437
|
+
...(params.macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, params.macroTasks.map((task) => ({
|
|
3438
|
+
...(task.id ? { id: task.id } : {}),
|
|
3439
|
+
title: task.title,
|
|
3440
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
3441
|
+
status: task.status.trim(),
|
|
3442
|
+
})), now) } : {}),
|
|
3443
|
+
updatedAt: now,
|
|
3444
|
+
}));
|
|
3445
|
+
}
|
|
3446
|
+
catch (error) {
|
|
3447
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
3448
|
+
return { content: [{ type: "text", text: message }], details: {} };
|
|
3309
3449
|
}
|
|
3310
|
-
requirement.updatedAt = now;
|
|
3311
|
-
await st.saveRequirements(requirements);
|
|
3312
3450
|
await st.writeGenerated();
|
|
3313
|
-
return { content: [{ type: "text", text: `Requirement updated: ${
|
|
3451
|
+
return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
|
|
3314
3452
|
},
|
|
3315
3453
|
});
|
|
3316
3454
|
pi.registerTool({
|
|
@@ -3324,14 +3462,17 @@ export default function planPiExtension(pi) {
|
|
|
3324
3462
|
const st = await requirePlan(ctx);
|
|
3325
3463
|
if (!st)
|
|
3326
3464
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3465
|
+
let deleted = false;
|
|
3466
|
+
await st.updateRequirements((document) => {
|
|
3467
|
+
const requirements = document.requirements.filter((entry) => entry.id !== params.requirementId);
|
|
3468
|
+
deleted = requirements.length !== document.requirements.length;
|
|
3469
|
+
return deleted ? { requirements } : document;
|
|
3470
|
+
});
|
|
3471
|
+
if (!deleted) {
|
|
3472
|
+
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: { errorCode: "NOT_FOUND", deleted: false } };
|
|
3331
3473
|
}
|
|
3332
|
-
await st.saveRequirements({ requirements: next });
|
|
3333
3474
|
await st.writeGenerated();
|
|
3334
|
-
return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: params.requirementId } };
|
|
3475
|
+
return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: true, requirementId: params.requirementId } };
|
|
3335
3476
|
},
|
|
3336
3477
|
});
|
|
3337
3478
|
pi.registerTool({
|
|
@@ -3354,7 +3495,7 @@ export default function planPiExtension(pi) {
|
|
|
3354
3495
|
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3355
3496
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
|
|
3356
3497
|
return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
|
|
3357
|
-
return {
|
|
3498
|
+
return mutationNoFieldsFailure({ entity: "idea", ref: params.idea, operation: "update", mutableFields: ["title", "description"], retryCommand: `idea_update ${params.idea}` }); const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}` }], details: { idea, updated: true } }; },
|
|
3358
3499
|
});
|
|
3359
3500
|
pi.registerTool({
|
|
3360
3501
|
name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
|
|
@@ -3408,19 +3549,47 @@ export default function planPiExtension(pi) {
|
|
|
3408
3549
|
pi.registerTool({
|
|
3409
3550
|
name: "plan_get",
|
|
3410
3551
|
label: "Plan Get",
|
|
3411
|
-
description: "Read the full plan: manifest, project, features, phases (with their tasks), and
|
|
3552
|
+
description: "Read the full plan plus explicit active-task evidence: manifest, project (including Accepted Decisions), features, phases (with their tasks), requirements, activeTaskState, and activeTasks. Never infer that no task is active from counts or omitted detail.",
|
|
3412
3553
|
parameters: Type.Object({}),
|
|
3413
3554
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
3414
3555
|
const st = await requirePlan(ctx);
|
|
3415
3556
|
if (!st)
|
|
3416
3557
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
3417
3558
|
const plan = await st.loadAll();
|
|
3559
|
+
const activeTasks = plan.phases.flatMap((phase) => phase.tasks
|
|
3560
|
+
.filter((task) => task.status === "in-progress")
|
|
3561
|
+
.map((task) => ({
|
|
3562
|
+
id: task.id,
|
|
3563
|
+
ref: `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
|
|
3564
|
+
title: task.title,
|
|
3565
|
+
status: task.status,
|
|
3566
|
+
})));
|
|
3567
|
+
const activeTaskEvidence = activeTasks.length === 0
|
|
3568
|
+
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
3569
|
+
: `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
|
|
3570
|
+
const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
|
|
3418
3571
|
return {
|
|
3419
|
-
content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements` }],
|
|
3420
|
-
details: plan,
|
|
3572
|
+
content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements. ${activeTaskEvidence}\n${projectDecisionContext.content}` }],
|
|
3573
|
+
details: { ...plan, activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict", activeTasks },
|
|
3421
3574
|
};
|
|
3422
3575
|
},
|
|
3423
3576
|
});
|
|
3577
|
+
pi.registerTool({
|
|
3578
|
+
name: "description_freshness",
|
|
3579
|
+
label: "Description Freshness",
|
|
3580
|
+
description: "Preview deterministic child-to-parent description drift. Returns exact stale phase/feature refs and leaf-to-root reconciliation steps without rewriting user-authored descriptions.",
|
|
3581
|
+
parameters: Type.Object({}),
|
|
3582
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
3583
|
+
const st = await requirePlan(ctx);
|
|
3584
|
+
if (!st)
|
|
3585
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3586
|
+
const freshness = await st.previewDescriptionReconciliation();
|
|
3587
|
+
if (!freshness.reconciliationRequired)
|
|
3588
|
+
return { content: [{ type: "text", text: "Description hierarchy is fresh; no parent reconciliation is required." }], details: freshness };
|
|
3589
|
+
const lines = freshness.reconciliationPreview.map((step) => `- ${step.ownerRef} after ${step.causedByRef}: ${step.action}`);
|
|
3590
|
+
return { content: [{ type: "text", text: `Parent description reconciliation required (${freshness.staleParentRefs.length}):\n${lines.join("\n")}` }], details: freshness };
|
|
3591
|
+
},
|
|
3592
|
+
});
|
|
3424
3593
|
pi.registerTool({
|
|
3425
3594
|
name: "plan_render",
|
|
3426
3595
|
label: "Plan Render",
|
|
@@ -3506,7 +3675,7 @@ export default function planPiExtension(pi) {
|
|
|
3506
3675
|
return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
|
|
3507
3676
|
const result = boundedPage(list, 1, 10);
|
|
3508
3677
|
const { items, ...pageInfo } = result;
|
|
3509
|
-
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
|
|
3678
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
3510
3679
|
return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Page 1/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}` }], details: { deprecated: true, count: items.length, ...pageInfo, handoffs: items } };
|
|
3511
3680
|
},
|
|
3512
3681
|
});
|
|
@@ -3612,14 +3781,14 @@ export default function planPiExtension(pi) {
|
|
|
3612
3781
|
return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
|
|
3613
3782
|
const result = boundedPage(list, params.page ?? 1, params.pageSize ?? 10);
|
|
3614
3783
|
const { items, ...pageInfo } = result;
|
|
3615
|
-
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
|
|
3784
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
3616
3785
|
return { content: [{ type: "text", text: `Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}` }], details: { count: items.length, ...pageInfo, handoffs: items } };
|
|
3617
3786
|
},
|
|
3618
3787
|
});
|
|
3619
3788
|
pi.registerTool({
|
|
3620
3789
|
name: "handoff_show",
|
|
3621
3790
|
label: "Handoff Show",
|
|
3622
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
|
|
3791
|
+
description: "Read the active entity-scoped handoff of a phase, or its latest terminal archive, together with a bounded priority-ordered phase work map. Reread the canonical phase and relevant sibling tasks before proposing work so capability ownership is not duplicated. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
|
|
3623
3792
|
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
|
|
3624
3793
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3625
3794
|
const st = await requirePlan(ctx);
|
|
@@ -3630,18 +3799,98 @@ export default function planPiExtension(pi) {
|
|
|
3630
3799
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3631
3800
|
const phase = await st.loadPhase(r.phase.id);
|
|
3632
3801
|
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
3633
|
-
if (!bounded.content.trim())
|
|
3634
|
-
|
|
3802
|
+
if (!bounded.content.trim()) {
|
|
3803
|
+
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
3804
|
+
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
3805
|
+
if (archived) {
|
|
3806
|
+
const archivedContent = boundedHandoffForTransport(archived.content);
|
|
3807
|
+
const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
|
|
3808
|
+
const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
|
|
3809
|
+
return {
|
|
3810
|
+
content: [{ type: "text", text: [
|
|
3811
|
+
`Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
|
|
3812
|
+
...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
|
|
3813
|
+
"",
|
|
3814
|
+
archivedContent.content,
|
|
3815
|
+
].join("\n") }],
|
|
3816
|
+
details: {
|
|
3817
|
+
phaseRef: r.compositeRef,
|
|
3818
|
+
phaseId: phase.id,
|
|
3819
|
+
active: false,
|
|
3820
|
+
archived: true,
|
|
3821
|
+
archiveReason: archived.reason,
|
|
3822
|
+
archivedAt: archived.archivedAt,
|
|
3823
|
+
archiveFile: archived.file,
|
|
3824
|
+
...archivedContent,
|
|
3825
|
+
supportingDocuments,
|
|
3826
|
+
empty: false,
|
|
3827
|
+
resumeReady: false,
|
|
3828
|
+
handoffAudit: phase.handoffAudit,
|
|
3829
|
+
},
|
|
3830
|
+
};
|
|
3831
|
+
}
|
|
3832
|
+
return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit } };
|
|
3833
|
+
}
|
|
3834
|
+
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
3835
|
+
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
3836
|
+
const contentHash = handoffContentHash(phase.handoff);
|
|
3837
|
+
const persistenceVerified = Boolean(phase.handoffAudit
|
|
3838
|
+
&& phase.handoffAudit.contentHash === contentHash
|
|
3839
|
+
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
3840
|
+
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
3841
|
+
const status = resumeReady
|
|
3842
|
+
? "Resume-ready: persisted compact capsule read-back completed."
|
|
3843
|
+
: "NOT resume-ready: after reading this persisted capsule, call handoff_verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
|
|
3635
3844
|
return {
|
|
3636
|
-
content: [{ type: "text", text: `Handoff for ${r.compositeRef}
|
|
3637
|
-
details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit },
|
|
3845
|
+
content: [{ type: "text", text: `Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}` }],
|
|
3846
|
+
details: { phaseRef: r.compositeRef, phaseId: r.phase.id, phaseWorkMap, ...bounded, contentHash, persistenceVerified, resumeReady, verificationRequired: !resumeReady, handoffAudit: phase.handoffAudit },
|
|
3638
3847
|
};
|
|
3639
3848
|
},
|
|
3640
3849
|
});
|
|
3850
|
+
pi.registerTool({
|
|
3851
|
+
name: "handoff_verify",
|
|
3852
|
+
label: "Handoff Verify",
|
|
3853
|
+
description: "Finalize one persisted handoff as resume-ready after handoff_show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
|
|
3854
|
+
parameters: Type.Object({
|
|
3855
|
+
phaseRef: Type.String(),
|
|
3856
|
+
expectedContentHash: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
3857
|
+
sourceReviews: Type.Optional(Type.Array(Type.Object({
|
|
3858
|
+
source: Type.Union([Type.Literal("conversation"), Type.Literal("planner-entities"), Type.Literal("working-tree"), Type.Literal("verification-runtime"), Type.Literal("peer-agent-output")]),
|
|
3859
|
+
detail: Type.String({ minLength: 12 }),
|
|
3860
|
+
}))),
|
|
3861
|
+
omissionsFound: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
3862
|
+
}),
|
|
3863
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3864
|
+
const st = await requirePlan(ctx);
|
|
3865
|
+
if (!st)
|
|
3866
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3867
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3868
|
+
if (!r.ok)
|
|
3869
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { resumeReady: false, error: r.error } };
|
|
3870
|
+
try {
|
|
3871
|
+
const result = await st.verifyPhaseHandoffReadBack(r.phase.id, {
|
|
3872
|
+
expectedContentHash: params.expectedContentHash,
|
|
3873
|
+
...(params.sourceReviews ? { sourceReviews: params.sourceReviews } : {}),
|
|
3874
|
+
...(params.omissionsFound ? { omissionsFound: params.omissionsFound } : {}),
|
|
3875
|
+
});
|
|
3876
|
+
await st.writeGenerated();
|
|
3877
|
+
return {
|
|
3878
|
+
content: [{ type: "text", text: `✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.` }],
|
|
3879
|
+
details: { phaseRef: r.compositeRef, ...result, resumeReady: true, verificationRequired: false },
|
|
3880
|
+
};
|
|
3881
|
+
}
|
|
3882
|
+
catch (error) {
|
|
3883
|
+
if (error instanceof HandoffContractError)
|
|
3884
|
+
return handoffContractFailure(error);
|
|
3885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3886
|
+
return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
|
|
3887
|
+
}
|
|
3888
|
+
},
|
|
3889
|
+
});
|
|
3641
3890
|
pi.registerTool({
|
|
3642
3891
|
name: "handoff_prepare",
|
|
3643
3892
|
label: "Handoff Prepare",
|
|
3644
|
-
description: "
|
|
3893
|
+
description: "Prepare one exact phase before writing a handoff. Returns a compact resume capsule scaffold, bounded phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; do not duplicate planner evidence in Markdown.",
|
|
3645
3894
|
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3646
3895
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3647
3896
|
const st = await requirePlan(ctx);
|
|
@@ -3652,7 +3901,7 @@ export default function planPiExtension(pi) {
|
|
|
3652
3901
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3653
3902
|
try {
|
|
3654
3903
|
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3655
|
-
const { handoff, ...auditDetails } = audit;
|
|
3904
|
+
const { handoff, completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...auditDetails } = audit;
|
|
3656
3905
|
const boundedExisting = boundedHandoffForTransport(handoff);
|
|
3657
3906
|
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
3658
3907
|
const existing = boundedExisting.content.trim() || "(none)";
|
|
@@ -3660,17 +3909,30 @@ export default function planPiExtension(pi) {
|
|
|
3660
3909
|
content: [{ type: "text", text: [
|
|
3661
3910
|
`Handoff preparation audit for ${r.compositeRef}`,
|
|
3662
3911
|
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3912
|
+
"Required human inputs before drafting:",
|
|
3913
|
+
...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
|
|
3914
|
+
"Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
|
|
3915
|
+
"Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
|
|
3916
|
+
audit.draftTemplate,
|
|
3917
|
+
"",
|
|
3663
3918
|
"Done tasks missing durable completion/verification evidence:",
|
|
3664
3919
|
missing,
|
|
3665
3920
|
"",
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3921
|
+
"Compact handoff contract: include only the exact focus/resume point, current or partial state, preservation constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
|
|
3922
|
+
"No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
|
|
3923
|
+
"Completeness audit and cold-start evidence are planner-owned metadata. Do not copy their categories or source reviews into Markdown; provide legacy evidence only when it already exists.",
|
|
3924
|
+
`Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
|
|
3669
3925
|
"",
|
|
3670
3926
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
3671
3927
|
existing,
|
|
3672
3928
|
].join("\n") }],
|
|
3673
|
-
details: {
|
|
3929
|
+
details: {
|
|
3930
|
+
phaseRef: r.compositeRef,
|
|
3931
|
+
...auditDetails,
|
|
3932
|
+
evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
|
|
3933
|
+
existingHandoffLength: boundedExisting.fullLength,
|
|
3934
|
+
existingHandoffTruncated: boundedExisting.truncated,
|
|
3935
|
+
},
|
|
3674
3936
|
};
|
|
3675
3937
|
}
|
|
3676
3938
|
catch (error) {
|
|
@@ -3682,10 +3944,11 @@ export default function planPiExtension(pi) {
|
|
|
3682
3944
|
pi.registerTool({
|
|
3683
3945
|
name: "handoff_write",
|
|
3684
3946
|
label: "Handoff Write",
|
|
3685
|
-
description: "
|
|
3947
|
+
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
|
|
3686
3948
|
parameters: Type.Object({
|
|
3687
3949
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3688
3950
|
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3951
|
+
reason: Type.Optional(Type.String({ description: "Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown." })),
|
|
3689
3952
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3690
3953
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3691
3954
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
@@ -3696,7 +3959,19 @@ export default function planPiExtension(pi) {
|
|
|
3696
3959
|
entries: Type.Array(Type.Object({
|
|
3697
3960
|
category: Type.String({ description: `One required category id from handoff_prepare: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
|
|
3698
3961
|
status: Type.Union([Type.Literal("captured"), Type.Literal("not-applicable")]),
|
|
3699
|
-
detail: Type.String({ description: "
|
|
3962
|
+
detail: Type.String({ description: "Legacy evidence detail; omit the entire audit for compact handoffs." }),
|
|
3963
|
+
})),
|
|
3964
|
+
})),
|
|
3965
|
+
coldStartInventory: Type.Optional(Type.Object({
|
|
3966
|
+
version: Type.Literal(HANDOFF_COLD_START_INVENTORY_VERSION),
|
|
3967
|
+
sourceReviews: Type.Array(Type.Object({
|
|
3968
|
+
source: Type.String({ description: `One required source review id: ${HANDOFF_COLD_START_SOURCE_REVIEWS.map((entry) => entry.id).join(", ")}` }),
|
|
3969
|
+
detail: Type.String({ description: "Substantive summary of what was reviewed and which facts were extracted before drafting." }),
|
|
3970
|
+
})),
|
|
3971
|
+
entries: Type.Array(Type.Object({
|
|
3972
|
+
category: Type.String({ description: `One required cold-start category id from handoff_prepare: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
|
|
3973
|
+
items: Type.Array(Type.String({ description: "Legacy resume evidence item; omit the entire inventory for compact handoffs." })),
|
|
3974
|
+
notApplicableReason: Type.Optional(Type.String({ description: "Legacy evidence explanation." })),
|
|
3700
3975
|
})),
|
|
3701
3976
|
})),
|
|
3702
3977
|
supportingDocuments: Type.Optional(Type.Array(Type.Object({
|
|
@@ -3749,7 +4024,9 @@ export default function planPiExtension(pi) {
|
|
|
3749
4024
|
if (!params.confirmed)
|
|
3750
4025
|
return { content: [{ type: "text", text: `Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run handoff_prepare and retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
|
|
3751
4026
|
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3752
|
-
return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: {
|
|
4027
|
+
return { isError: true, content: [{ type: "text", text: "❌ Handoff refresh denied [HANDOFF_PREFLIGHT_REQUIRED]: Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { errorCode: "HANDOFF_PREFLIGHT_REQUIRED", persisted: false } };
|
|
4028
|
+
if (!params.reason?.trim())
|
|
4029
|
+
return { isError: true, content: [{ type: "text", text: "❌ Handoff refresh denied [HANDOFF_REASON_REQUIRED]: Handoff reason is required before persistence. Created at and Updated at are generated by the planner; do not add them to Markdown." }], details: { errorCode: "HANDOFF_REASON_REQUIRED", persisted: false, requiredInputs: ["title", "reason"], generatedMetadata: ["Created at", "Updated at"] } };
|
|
3753
4030
|
const phases = await st.loadAllPhases();
|
|
3754
4031
|
const features = (await st.loadFeatures()).features;
|
|
3755
4032
|
const taskUpdates = [];
|
|
@@ -3768,10 +4045,12 @@ export default function planPiExtension(pi) {
|
|
|
3768
4045
|
}
|
|
3769
4046
|
try {
|
|
3770
4047
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
4048
|
+
reason: params.reason.trim(),
|
|
3771
4049
|
content: body,
|
|
3772
4050
|
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3773
4051
|
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3774
4052
|
...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
|
|
4053
|
+
...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
|
|
3775
4054
|
...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
|
|
3776
4055
|
contextSync: {
|
|
3777
4056
|
taskUpdates,
|
|
@@ -3787,13 +4066,20 @@ export default function planPiExtension(pi) {
|
|
|
3787
4066
|
});
|
|
3788
4067
|
await st.writeGenerated();
|
|
3789
4068
|
return {
|
|
3790
|
-
content: [{ type: "text", text:
|
|
4069
|
+
content: [{ type: "text", text: `⚠️ Handoff candidate persisted on ${r.compositeRef}, but it is NOT resume-ready yet. Required next action: call handoff_show ${r.compositeRef}, read the persisted compact capsule, then call handoff_verify with the returned contentHash. Rewrite only if resume-critical context is missing.` }],
|
|
3791
4070
|
details: {
|
|
3792
4071
|
phaseRef: r.compositeRef,
|
|
3793
4072
|
phaseId: r.phase.id,
|
|
3794
4073
|
updatedTaskIds: result.updatedTaskIds,
|
|
3795
4074
|
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
3796
4075
|
handoffAudit: result.handoffAudit,
|
|
4076
|
+
persisted: true,
|
|
4077
|
+
resumeReady: false,
|
|
4078
|
+
verificationRequired: true,
|
|
4079
|
+
nextActions: [
|
|
4080
|
+
`Call handoff_show ${r.compositeRef} and read the persisted compact capsule.`,
|
|
4081
|
+
"Call handoff_verify with the shown contentHash; legacy sourceReviews and omissionsFound are optional because the planner derives omitted evidence from persisted state.",
|
|
4082
|
+
],
|
|
3797
4083
|
},
|
|
3798
4084
|
};
|
|
3799
4085
|
}
|
|
@@ -3875,13 +4161,14 @@ export default function planPiExtension(pi) {
|
|
|
3875
4161
|
pi.registerTool({
|
|
3876
4162
|
name: "feature_get",
|
|
3877
4163
|
label: "Feature Get",
|
|
3878
|
-
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
4164
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, and derived linked requirements.",
|
|
3879
4165
|
parameters: Type.Object({
|
|
3880
4166
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3881
4167
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
3882
4168
|
}),
|
|
3883
4169
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3884
4170
|
const st = await requirePlan(ctx);
|
|
4171
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
3885
4172
|
if (!st)
|
|
3886
4173
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3887
4174
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3890,19 +4177,30 @@ export default function planPiExtension(pi) {
|
|
|
3890
4177
|
if (!resolvedFeature.ok)
|
|
3891
4178
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3892
4179
|
const feature = resolvedFeature.feature;
|
|
3893
|
-
if (params.full)
|
|
3894
|
-
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3895
4180
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3896
4181
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3897
4182
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
|
|
3898
4183
|
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
3899
4184
|
const requirementsBlock = linkedRequirements.length > 0
|
|
3900
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4185
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
3901
4186
|
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3902
|
-
|
|
3903
|
-
content: [{ type: "text", text:
|
|
3904
|
-
|
|
4187
|
+
if (!params.full)
|
|
4188
|
+
return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
|
|
4189
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
|
|
4190
|
+
const result = {
|
|
4191
|
+
content: [{ type: "text", text: `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
|
|
4192
|
+
details: {
|
|
4193
|
+
feature: {
|
|
4194
|
+
ref: formatFeatureRef(feature.number),
|
|
4195
|
+
description: feature.description,
|
|
4196
|
+
descriptionRef: feature.descriptionRef ?? "",
|
|
4197
|
+
acceptedDecisions: feature.acceptedDecisions,
|
|
4198
|
+
},
|
|
4199
|
+
linkedRequirements,
|
|
4200
|
+
},
|
|
3905
4201
|
};
|
|
4202
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
|
|
4203
|
+
return result;
|
|
3906
4204
|
},
|
|
3907
4205
|
});
|
|
3908
4206
|
pi.registerTool({
|
|
@@ -3918,44 +4216,46 @@ export default function planPiExtension(pi) {
|
|
|
3918
4216
|
const st = await requirePlan(ctx);
|
|
3919
4217
|
if (!st)
|
|
3920
4218
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
4219
|
+
return st.runBatch(async () => {
|
|
4220
|
+
const now = nowISO();
|
|
4221
|
+
const status = params.status ?? "planned";
|
|
4222
|
+
const currentFeatures = (await st.loadFeatures()).features;
|
|
4223
|
+
const id = createFeatureId();
|
|
4224
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
4225
|
+
const priority = await st.nextPriority("feature");
|
|
4226
|
+
const feature = {
|
|
4227
|
+
id,
|
|
4228
|
+
number: identity.number,
|
|
4229
|
+
shortId: identity.shortId,
|
|
4230
|
+
priority,
|
|
4231
|
+
name: params.name,
|
|
4232
|
+
description: params.description ?? "",
|
|
4233
|
+
descriptionUpdatedAt: now,
|
|
4234
|
+
status,
|
|
4235
|
+
discussedAt: "",
|
|
4236
|
+
contextReady: false,
|
|
4237
|
+
contextReadyReason: "",
|
|
4238
|
+
startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
|
|
4239
|
+
endDate: "",
|
|
4240
|
+
workDone: "",
|
|
4241
|
+
workRemaining: "",
|
|
4242
|
+
acceptedDecisions: [],
|
|
4243
|
+
phaseIds: [],
|
|
4244
|
+
dependsOn: [],
|
|
4245
|
+
statusLog: [],
|
|
4246
|
+
sessionInfo: [],
|
|
4247
|
+
createdAt: now,
|
|
4248
|
+
updatedAt: now,
|
|
4249
|
+
};
|
|
4250
|
+
// Atomic read-modify-write: serializes concurrent feature_create calls
|
|
4251
|
+
// (batch) so they don't overwrite each other (last-write-wins race).
|
|
4252
|
+
await st.updateFeatures((doc) => {
|
|
4253
|
+
doc.features.push(feature);
|
|
4254
|
+
return doc;
|
|
4255
|
+
});
|
|
4256
|
+
await st.writeGenerated();
|
|
4257
|
+
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3956
4258
|
});
|
|
3957
|
-
await st.writeGenerated();
|
|
3958
|
-
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3959
4259
|
},
|
|
3960
4260
|
});
|
|
3961
4261
|
pi.registerTool({
|
|
@@ -4032,13 +4332,13 @@ export default function planPiExtension(pi) {
|
|
|
4032
4332
|
pi.registerTool({
|
|
4033
4333
|
name: "feature_update",
|
|
4034
4334
|
label: "Feature Update",
|
|
4035
|
-
description: "Update one or more
|
|
4335
|
+
description: "Update one or more canonical feature fields. Feature status is derived from child phases/tasks and cannot be changed directly.",
|
|
4036
4336
|
parameters: Type.Object({
|
|
4037
4337
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4038
4338
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
4039
4339
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
4040
4340
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4041
|
-
status: Type.Optional(Type.String({ description: "
|
|
4341
|
+
status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Feature status is derived from child phases/tasks; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
|
|
4042
4342
|
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
4043
4343
|
endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
4044
4344
|
workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
|
|
@@ -4051,7 +4351,7 @@ export default function planPiExtension(pi) {
|
|
|
4051
4351
|
rationale: Type.String(),
|
|
4052
4352
|
implementationNotes: Type.String(),
|
|
4053
4353
|
acceptedAt: Type.String(),
|
|
4054
|
-
}), { description: "
|
|
4354
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4055
4355
|
}),
|
|
4056
4356
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4057
4357
|
const st = await requirePlan(ctx);
|
|
@@ -4065,6 +4365,13 @@ export default function planPiExtension(pi) {
|
|
|
4065
4365
|
if (params.status !== undefined) {
|
|
4066
4366
|
return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
4067
4367
|
}
|
|
4368
|
+
if (params.acceptedDecisions !== undefined) {
|
|
4369
|
+
return {
|
|
4370
|
+
content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
|
|
4371
|
+
details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
|
|
4372
|
+
isError: true,
|
|
4373
|
+
};
|
|
4374
|
+
}
|
|
4068
4375
|
const mutableFields = [
|
|
4069
4376
|
"name",
|
|
4070
4377
|
"description",
|
|
@@ -4074,7 +4381,6 @@ export default function planPiExtension(pi) {
|
|
|
4074
4381
|
"workDone",
|
|
4075
4382
|
"workRemaining",
|
|
4076
4383
|
"priority",
|
|
4077
|
-
"acceptedDecisions",
|
|
4078
4384
|
];
|
|
4079
4385
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4080
4386
|
if (receivedFields.length === 0) {
|
|
@@ -4120,8 +4426,6 @@ export default function planPiExtension(pi) {
|
|
|
4120
4426
|
feature.endDate = params.endDate.trim();
|
|
4121
4427
|
if (params.priority !== undefined)
|
|
4122
4428
|
feature.priority = params.priority;
|
|
4123
|
-
if (params.acceptedDecisions !== undefined)
|
|
4124
|
-
feature.acceptedDecisions = params.acceptedDecisions;
|
|
4125
4429
|
feature.updatedAt = nowISO();
|
|
4126
4430
|
return doc;
|
|
4127
4431
|
});
|
|
@@ -4160,22 +4464,24 @@ export default function planPiExtension(pi) {
|
|
|
4160
4464
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4161
4465
|
const featureId = resolvedFeature.feature.id;
|
|
4162
4466
|
let deleted = false;
|
|
4163
|
-
await st.updateFeatures((doc) => {
|
|
4164
|
-
const before = doc.features.length;
|
|
4165
|
-
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
4166
|
-
if (doc.features.length !== before)
|
|
4167
|
-
deleted = true;
|
|
4168
|
-
return doc;
|
|
4169
|
-
});
|
|
4170
4467
|
let cascadeCount = 0;
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4468
|
+
await st.runBatch(async () => {
|
|
4469
|
+
await st.updateFeatures((doc) => {
|
|
4470
|
+
const before = doc.features.length;
|
|
4471
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
4472
|
+
if (doc.features.length !== before)
|
|
4473
|
+
deleted = true;
|
|
4474
|
+
return doc;
|
|
4475
|
+
});
|
|
4476
|
+
if (params.cascade) {
|
|
4477
|
+
const phases = await st.loadAllPhases();
|
|
4478
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
4479
|
+
await st.deletePhase(phase.id);
|
|
4480
|
+
cascadeCount += 1;
|
|
4481
|
+
}
|
|
4176
4482
|
}
|
|
4177
|
-
|
|
4178
|
-
|
|
4483
|
+
await st.writeGenerated();
|
|
4484
|
+
});
|
|
4179
4485
|
return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
|
|
4180
4486
|
},
|
|
4181
4487
|
});
|
|
@@ -4214,32 +4520,44 @@ export default function planPiExtension(pi) {
|
|
|
4214
4520
|
pi.registerTool({
|
|
4215
4521
|
name: "phase_get",
|
|
4216
4522
|
label: "Phase Get",
|
|
4217
|
-
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
4523
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, and derived linked requirements.",
|
|
4218
4524
|
parameters: Type.Object({
|
|
4219
4525
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4220
4526
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
4221
4527
|
}),
|
|
4222
4528
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4223
4529
|
const st = await requirePlan(ctx);
|
|
4530
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4224
4531
|
if (!st)
|
|
4225
4532
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4226
4533
|
const features = (await st.loadFeatures()).features;
|
|
4227
4534
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
4228
4535
|
if (!phase)
|
|
4229
4536
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4230
|
-
if (params.full)
|
|
4231
|
-
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
4232
4537
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
4233
4538
|
const reqCount = linkedRequirements.length;
|
|
4234
4539
|
const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
|
|
4235
4540
|
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
4236
4541
|
const requirementsBlock = reqCount > 0
|
|
4237
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4542
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
4238
4543
|
: "";
|
|
4239
|
-
|
|
4240
|
-
content: [{ type: "text", text:
|
|
4241
|
-
|
|
4544
|
+
if (!params.full)
|
|
4545
|
+
return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
|
|
4546
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
|
|
4547
|
+
const result = {
|
|
4548
|
+
content: [{ type: "text", text: `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
|
|
4549
|
+
details: {
|
|
4550
|
+
phase: {
|
|
4551
|
+
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
4552
|
+
description: phase.description,
|
|
4553
|
+
descriptionRef: phase.descriptionRef ?? "",
|
|
4554
|
+
acceptedDecisions: phase.acceptedDecisions,
|
|
4555
|
+
},
|
|
4556
|
+
linkedRequirements,
|
|
4557
|
+
},
|
|
4242
4558
|
};
|
|
4559
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
|
|
4560
|
+
return result;
|
|
4243
4561
|
},
|
|
4244
4562
|
});
|
|
4245
4563
|
pi.registerTool({
|
|
@@ -4274,7 +4592,7 @@ export default function planPiExtension(pi) {
|
|
|
4274
4592
|
if (!existingFeature)
|
|
4275
4593
|
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
4276
4594
|
let phase;
|
|
4277
|
-
await withFeatureLock(featureId, async () => {
|
|
4595
|
+
await st.runBatch(() => withFeatureLock(featureId, async () => {
|
|
4278
4596
|
const id = createPhaseId();
|
|
4279
4597
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
4280
4598
|
const priority = await st.nextPriority("phase", featureId);
|
|
@@ -4307,7 +4625,7 @@ export default function planPiExtension(pi) {
|
|
|
4307
4625
|
return doc;
|
|
4308
4626
|
});
|
|
4309
4627
|
await st.writeGenerated();
|
|
4310
|
-
});
|
|
4628
|
+
}));
|
|
4311
4629
|
if (!phase)
|
|
4312
4630
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
4313
4631
|
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
@@ -4315,17 +4633,97 @@ export default function planPiExtension(pi) {
|
|
|
4315
4633
|
},
|
|
4316
4634
|
});
|
|
4317
4635
|
pi.registerTool({
|
|
4318
|
-
name: "
|
|
4319
|
-
label: "Phase
|
|
4320
|
-
description: "
|
|
4636
|
+
name: "phase_discuss",
|
|
4637
|
+
label: "Phase Discuss",
|
|
4638
|
+
description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
|
|
4321
4639
|
parameters: Type.Object({
|
|
4322
4640
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
summary: Type.Optional(Type.String(
|
|
4326
|
-
|
|
4641
|
+
goal: Type.Optional(Type.String({ description: "Backward-compatible single main goal" })),
|
|
4642
|
+
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace the phase goals list" })),
|
|
4643
|
+
summary: Type.Optional(Type.String()),
|
|
4644
|
+
scope: Type.Optional(Type.String({ description: "Current phase scope; stored as the phase description" })),
|
|
4327
4645
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4328
|
-
|
|
4646
|
+
nonGoals: Type.Optional(Type.Array(Type.String())),
|
|
4647
|
+
dependencies: Type.Optional(Type.Array(Type.String())),
|
|
4648
|
+
risks: Type.Optional(Type.Array(Type.String())),
|
|
4649
|
+
openQuestions: Type.Optional(Type.Array(Type.String())),
|
|
4650
|
+
completionCriteria: Type.Optional(Type.Array(Type.String())),
|
|
4651
|
+
}),
|
|
4652
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4653
|
+
const st = await requirePlan(ctx);
|
|
4654
|
+
if (!st)
|
|
4655
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4656
|
+
const features = (await st.loadFeatures()).features;
|
|
4657
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
4658
|
+
if (!resolvedPhase)
|
|
4659
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: { discussed: false, errorCode: "PHASE_NOT_FOUND" }, isError: true };
|
|
4660
|
+
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4661
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4662
|
+
if (receivedFields.length === 0) {
|
|
4663
|
+
return mutationNoFieldsFailure({
|
|
4664
|
+
entity: "phase",
|
|
4665
|
+
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4666
|
+
operation: "discuss",
|
|
4667
|
+
mutableFields,
|
|
4668
|
+
retryCommand: `phase_discuss ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
|
|
4669
|
+
readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
|
|
4670
|
+
descriptionFallback: {
|
|
4671
|
+
entityId: resolvedPhase.id,
|
|
4672
|
+
inputField: "scope",
|
|
4673
|
+
storedField: "phase description",
|
|
4674
|
+
descriptionRefField: "descriptionRef",
|
|
4675
|
+
},
|
|
4676
|
+
});
|
|
4677
|
+
}
|
|
4678
|
+
const phase = await st.updatePhase(resolvedPhase.id, (current) => {
|
|
4679
|
+
if (params.goals !== undefined)
|
|
4680
|
+
current.goals = params.goals.map((item) => item.trim()).filter(Boolean);
|
|
4681
|
+
else if (params.goal !== undefined)
|
|
4682
|
+
current.goals = [params.goal.trim()].filter(Boolean);
|
|
4683
|
+
if (params.summary !== undefined)
|
|
4684
|
+
current.summary = params.summary.trim();
|
|
4685
|
+
if (params.scope !== undefined)
|
|
4686
|
+
current.description = params.scope.trim();
|
|
4687
|
+
if (params.descriptionRef !== undefined) {
|
|
4688
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4689
|
+
if (descriptionRef)
|
|
4690
|
+
current.descriptionRef = descriptionRef;
|
|
4691
|
+
else
|
|
4692
|
+
delete current.descriptionRef;
|
|
4693
|
+
}
|
|
4694
|
+
if (params.nonGoals !== undefined)
|
|
4695
|
+
current.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
4696
|
+
if (params.dependencies !== undefined)
|
|
4697
|
+
current.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
4698
|
+
if (params.risks !== undefined)
|
|
4699
|
+
current.risks = params.risks.map((item) => item.trim()).filter(Boolean);
|
|
4700
|
+
if (params.openQuestions !== undefined)
|
|
4701
|
+
current.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
|
|
4702
|
+
if (params.completionCriteria !== undefined)
|
|
4703
|
+
current.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
4704
|
+
current.discussedAt = nowISO();
|
|
4705
|
+
current.contextReady = true;
|
|
4706
|
+
current.contextReadyReason = "Updated through phase_discuss tool.";
|
|
4707
|
+
current.updatedAt = nowISO();
|
|
4708
|
+
return current;
|
|
4709
|
+
});
|
|
4710
|
+
await st.writeGenerated();
|
|
4711
|
+
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
4712
|
+
return { content: [{ type: "text", text: `✅ Phase discussed/context ready: ${phaseRef} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, discussed: true, contextReady: true, updatedFields: receivedFields } };
|
|
4713
|
+
},
|
|
4714
|
+
});
|
|
4715
|
+
pi.registerTool({
|
|
4716
|
+
name: "phase_update",
|
|
4717
|
+
label: "Phase Update",
|
|
4718
|
+
description: "Update one or more canonical phase fields and optionally re-link the phase via featureId. Phase status is derived from task statuses and cannot be changed directly.",
|
|
4719
|
+
parameters: Type.Object({
|
|
4720
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4721
|
+
title: Type.Optional(Type.String({ description: "New title" })),
|
|
4722
|
+
status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
|
|
4723
|
+
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
4724
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
4725
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4726
|
+
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
4329
4727
|
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
4330
4728
|
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
|
|
4331
4729
|
nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
|
|
@@ -4340,7 +4738,7 @@ export default function planPiExtension(pi) {
|
|
|
4340
4738
|
rationale: Type.String(),
|
|
4341
4739
|
implementationNotes: Type.String(),
|
|
4342
4740
|
acceptedAt: Type.String(),
|
|
4343
|
-
}), { description: "
|
|
4741
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4344
4742
|
completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
|
|
4345
4743
|
}),
|
|
4346
4744
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -4362,7 +4760,14 @@ export default function planPiExtension(pi) {
|
|
|
4362
4760
|
if (params.status !== undefined) {
|
|
4363
4761
|
return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
4364
4762
|
}
|
|
4365
|
-
|
|
4763
|
+
if (params.acceptedDecisions !== undefined) {
|
|
4764
|
+
return {
|
|
4765
|
+
content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
|
|
4766
|
+
details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4767
|
+
isError: true,
|
|
4768
|
+
};
|
|
4769
|
+
}
|
|
4770
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
|
|
4366
4771
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4367
4772
|
if (receivedFields.length === 0) {
|
|
4368
4773
|
return mutationNoFieldsFailure({
|
|
@@ -4382,39 +4787,6 @@ export default function planPiExtension(pi) {
|
|
|
4382
4787
|
}
|
|
4383
4788
|
const prevFeatureId = phase.featureId;
|
|
4384
4789
|
let nextFeatureIdForUpdate = prevFeatureId;
|
|
4385
|
-
if (params.title !== undefined) {
|
|
4386
|
-
phase.title = params.title.trim();
|
|
4387
|
-
phase.slug = normalizeSlug(params.title);
|
|
4388
|
-
}
|
|
4389
|
-
if (params.summary !== undefined)
|
|
4390
|
-
phase.summary = params.summary.trim();
|
|
4391
|
-
if (params.description !== undefined)
|
|
4392
|
-
phase.description = params.description.trim();
|
|
4393
|
-
if (params.descriptionRef !== undefined) {
|
|
4394
|
-
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4395
|
-
if (descriptionRef)
|
|
4396
|
-
phase.descriptionRef = descriptionRef;
|
|
4397
|
-
else
|
|
4398
|
-
delete phase.descriptionRef;
|
|
4399
|
-
}
|
|
4400
|
-
if (params.priority !== undefined)
|
|
4401
|
-
phase.priority = params.priority;
|
|
4402
|
-
if (params.goals !== undefined)
|
|
4403
|
-
phase.goals = params.goals.map((item) => item.trim()).filter(Boolean);
|
|
4404
|
-
if (params.nonGoals !== undefined)
|
|
4405
|
-
phase.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
4406
|
-
if (params.dependencies !== undefined)
|
|
4407
|
-
phase.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
4408
|
-
if (params.risks !== undefined)
|
|
4409
|
-
phase.risks = params.risks.map((item) => item.trim()).filter(Boolean);
|
|
4410
|
-
if (params.openQuestions !== undefined)
|
|
4411
|
-
phase.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
|
|
4412
|
-
if (params.decisions !== undefined)
|
|
4413
|
-
phase.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
4414
|
-
if (params.acceptedDecisions !== undefined)
|
|
4415
|
-
phase.acceptedDecisions = params.acceptedDecisions;
|
|
4416
|
-
if (params.completionCriteria !== undefined)
|
|
4417
|
-
phase.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
4418
4790
|
if (params.featureId !== undefined) {
|
|
4419
4791
|
if (params.featureId === "") {
|
|
4420
4792
|
nextFeatureIdForUpdate = undefined;
|
|
@@ -4425,24 +4797,60 @@ export default function planPiExtension(pi) {
|
|
|
4425
4797
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4426
4798
|
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
4427
4799
|
}
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4800
|
+
}
|
|
4801
|
+
let persistedPhase;
|
|
4802
|
+
await st.runBatch(async () => {
|
|
4803
|
+
persistedPhase = await st.updatePhase(phaseId, (current) => {
|
|
4804
|
+
const next = {
|
|
4805
|
+
...current,
|
|
4806
|
+
...(params.title !== undefined ? { title: params.title.trim(), slug: normalizeSlug(params.title) } : {}),
|
|
4807
|
+
...(params.summary !== undefined ? { summary: params.summary.trim() } : {}),
|
|
4808
|
+
...(params.description !== undefined ? { description: params.description.trim() } : {}),
|
|
4809
|
+
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
|
4810
|
+
...(params.goals !== undefined ? { goals: params.goals.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4811
|
+
...(params.nonGoals !== undefined ? { nonGoals: params.nonGoals.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4812
|
+
...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4813
|
+
...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4814
|
+
...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4815
|
+
...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4816
|
+
...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4817
|
+
...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
|
|
4818
|
+
updatedAt: nowISO(),
|
|
4819
|
+
};
|
|
4820
|
+
if (params.descriptionRef !== undefined) {
|
|
4821
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4822
|
+
if (descriptionRef)
|
|
4823
|
+
next.descriptionRef = descriptionRef;
|
|
4824
|
+
else
|
|
4825
|
+
delete next.descriptionRef;
|
|
4826
|
+
}
|
|
4827
|
+
return next;
|
|
4828
|
+
});
|
|
4829
|
+
if (params.featureId !== undefined && nextFeatureIdForUpdate !== prevFeatureId) {
|
|
4830
|
+
await st.updateFeatures((document) => {
|
|
4831
|
+
for (const feature of document.features) {
|
|
4832
|
+
if (feature.id === prevFeatureId)
|
|
4833
|
+
feature.phaseIds = feature.phaseIds.filter((id) => id !== phaseId);
|
|
4834
|
+
if (feature.id === nextFeatureIdForUpdate && !feature.phaseIds.includes(phaseId))
|
|
4835
|
+
feature.phaseIds.push(phaseId);
|
|
4436
4836
|
}
|
|
4437
|
-
return
|
|
4837
|
+
return document;
|
|
4438
4838
|
});
|
|
4439
4839
|
}
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
await st.writeGenerated();
|
|
4840
|
+
await st.writeGenerated();
|
|
4841
|
+
});
|
|
4842
|
+
phase = persistedPhase;
|
|
4444
4843
|
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
4445
|
-
|
|
4844
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
4845
|
+
? await st.previewDescriptionReconciliation()
|
|
4846
|
+
: null;
|
|
4847
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
4848
|
+
.filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
|
|
4849
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
4850
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
4851
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
|
|
4852
|
+
: "";
|
|
4853
|
+
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...phase, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
|
|
4446
4854
|
},
|
|
4447
4855
|
});
|
|
4448
4856
|
pi.registerTool({
|
|
@@ -4480,7 +4888,7 @@ export default function planPiExtension(pi) {
|
|
|
4480
4888
|
implementationNotes: params.implementationNotes.trim(),
|
|
4481
4889
|
acceptedAt,
|
|
4482
4890
|
};
|
|
4483
|
-
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
4891
|
+
await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
4484
4892
|
let featureWritten = false;
|
|
4485
4893
|
try {
|
|
4486
4894
|
await st.updateFeatures((doc) => {
|
|
@@ -4511,11 +4919,128 @@ export default function planPiExtension(pi) {
|
|
|
4511
4919
|
}
|
|
4512
4920
|
throw error;
|
|
4513
4921
|
}
|
|
4514
|
-
});
|
|
4922
|
+
}));
|
|
4515
4923
|
await st.writeGenerated();
|
|
4516
4924
|
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
4517
4925
|
},
|
|
4518
4926
|
});
|
|
4927
|
+
pi.registerTool({
|
|
4928
|
+
name: "accepted_decision_create",
|
|
4929
|
+
label: "Accepted Decision Create",
|
|
4930
|
+
description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
|
|
4931
|
+
parameters: Type.Object({
|
|
4932
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
4933
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
4934
|
+
title: Type.String({ minLength: 1 }),
|
|
4935
|
+
decision: Type.Optional(Type.String()),
|
|
4936
|
+
rationale: Type.Optional(Type.String()),
|
|
4937
|
+
implementationNotes: Type.Optional(Type.String()),
|
|
4938
|
+
}),
|
|
4939
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4940
|
+
const st = await requirePlan(ctx);
|
|
4941
|
+
if (!st)
|
|
4942
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4943
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
4944
|
+
if (!target.ok)
|
|
4945
|
+
return { content: [{ type: "text", text: target.error }], details: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
4946
|
+
const createInput = {
|
|
4947
|
+
title: params.title,
|
|
4948
|
+
...(params.decision !== undefined ? { decision: params.decision } : {}),
|
|
4949
|
+
...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
|
|
4950
|
+
...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
|
|
4951
|
+
};
|
|
4952
|
+
try {
|
|
4953
|
+
const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
|
|
4954
|
+
await st.writeGenerated();
|
|
4955
|
+
return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
|
|
4956
|
+
}
|
|
4957
|
+
catch (error) {
|
|
4958
|
+
return acceptedDecisionMutationFailure(error, "created");
|
|
4959
|
+
}
|
|
4960
|
+
},
|
|
4961
|
+
});
|
|
4962
|
+
pi.registerTool({
|
|
4963
|
+
name: "accepted_decision_update",
|
|
4964
|
+
label: "Accepted Decision Update",
|
|
4965
|
+
description: "Update one accepted decision while preserving its id and acceptedAt timestamp.",
|
|
4966
|
+
parameters: Type.Object({
|
|
4967
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
4968
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
4969
|
+
decisionId: Type.String({ minLength: 1 }),
|
|
4970
|
+
title: Type.Optional(Type.String()),
|
|
4971
|
+
decision: Type.Optional(Type.String()),
|
|
4972
|
+
rationale: Type.Optional(Type.String()),
|
|
4973
|
+
implementationNotes: Type.Optional(Type.String()),
|
|
4974
|
+
}),
|
|
4975
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4976
|
+
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
4977
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4978
|
+
if (receivedFields.length === 0) {
|
|
4979
|
+
return {
|
|
4980
|
+
isError: true,
|
|
4981
|
+
content: [{ type: "text", text: "❌ ACCEPTED DECISION UPDATE FAILED [NO_MUTABLE_FIELDS_RECEIVED]\nNo accepted decision fields were supplied." }],
|
|
4982
|
+
details: noMutableFieldsReceived({
|
|
4983
|
+
entity: "acceptedDecision",
|
|
4984
|
+
ref: params.decisionId,
|
|
4985
|
+
operation: "update",
|
|
4986
|
+
mutableFields,
|
|
4987
|
+
retryCommand: "accepted_decision_update with title, decision, rationale, or implementationNotes",
|
|
4988
|
+
readBackCommand: "Read back the owning project, feature, phase, or task.",
|
|
4989
|
+
}),
|
|
4990
|
+
};
|
|
4991
|
+
}
|
|
4992
|
+
const st = await requirePlan(ctx);
|
|
4993
|
+
if (!st)
|
|
4994
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4995
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
4996
|
+
if (!target.ok)
|
|
4997
|
+
return { content: [{ type: "text", text: target.error }], details: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
4998
|
+
const updateInput = {
|
|
4999
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
5000
|
+
...(params.decision !== undefined ? { decision: params.decision } : {}),
|
|
5001
|
+
...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
|
|
5002
|
+
...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
|
|
5003
|
+
};
|
|
5004
|
+
try {
|
|
5005
|
+
const acceptedDecision = await st.updateAcceptedDecision(target.owner, params.decisionId, updateInput);
|
|
5006
|
+
await st.writeGenerated();
|
|
5007
|
+
return { content: [{ type: "text", text: `✅ Accepted decision updated on ${target.ref}: ${acceptedDecision.title}` }], details: { updated: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision, updatedFields: receivedFields } };
|
|
5008
|
+
}
|
|
5009
|
+
catch (error) {
|
|
5010
|
+
return acceptedDecisionMutationFailure(error, "updated");
|
|
5011
|
+
}
|
|
5012
|
+
},
|
|
5013
|
+
});
|
|
5014
|
+
pi.registerTool({
|
|
5015
|
+
name: "accepted_decision_delete",
|
|
5016
|
+
label: "Accepted Decision Delete",
|
|
5017
|
+
description: "Delete one accepted decision from the project, feature, phase, or task. Requires confirmed=true.",
|
|
5018
|
+
parameters: Type.Object({
|
|
5019
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
5020
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
5021
|
+
decisionId: Type.String({ minLength: 1 }),
|
|
5022
|
+
confirmed: Type.Boolean({ description: "Must be true to confirm deleting this accepted decision." }),
|
|
5023
|
+
}),
|
|
5024
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5025
|
+
const st = await requirePlan(ctx);
|
|
5026
|
+
if (!st)
|
|
5027
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5028
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
5029
|
+
if (!target.ok)
|
|
5030
|
+
return { content: [{ type: "text", text: target.error }], details: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
5031
|
+
if (!params.confirmed) {
|
|
5032
|
+
return { content: [{ type: "text", text: `Delete not confirmed for accepted decision ${params.decisionId} on ${target.ref}. Retry with confirmed=true.` }], details: { deleted: false, confirmRequired: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId } };
|
|
5033
|
+
}
|
|
5034
|
+
try {
|
|
5035
|
+
const acceptedDecision = await st.deleteAcceptedDecision(target.owner, params.decisionId);
|
|
5036
|
+
await st.writeGenerated();
|
|
5037
|
+
return { content: [{ type: "text", text: `✅ Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}` }], details: { deleted: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId, acceptedDecision } };
|
|
5038
|
+
}
|
|
5039
|
+
catch (error) {
|
|
5040
|
+
return acceptedDecisionMutationFailure(error, "deleted");
|
|
5041
|
+
}
|
|
5042
|
+
},
|
|
5043
|
+
});
|
|
4519
5044
|
pi.registerTool({
|
|
4520
5045
|
name: "phase_delete",
|
|
4521
5046
|
label: "Phase Delete",
|
|
@@ -4539,18 +5064,20 @@ export default function planPiExtension(pi) {
|
|
|
4539
5064
|
catch {
|
|
4540
5065
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4541
5066
|
}
|
|
4542
|
-
await st.
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
5067
|
+
await st.runBatch(async () => {
|
|
5068
|
+
await st.deletePhase(phaseId);
|
|
5069
|
+
if (phase.featureId) {
|
|
5070
|
+
await st.updateFeatures((doc) => {
|
|
5071
|
+
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
5072
|
+
if (feature) {
|
|
5073
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
5074
|
+
feature.updatedAt = nowISO();
|
|
5075
|
+
}
|
|
5076
|
+
return doc;
|
|
5077
|
+
});
|
|
5078
|
+
}
|
|
5079
|
+
await st.writeGenerated();
|
|
5080
|
+
});
|
|
4554
5081
|
return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
|
|
4555
5082
|
},
|
|
4556
5083
|
});
|
|
@@ -4598,21 +5125,20 @@ export default function planPiExtension(pi) {
|
|
|
4598
5125
|
pi.registerTool({
|
|
4599
5126
|
name: "task_get",
|
|
4600
5127
|
label: "Task Get",
|
|
4601
|
-
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
|
|
5128
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, resume checkpoint/advisory, and statusLog.",
|
|
4602
5129
|
parameters: Type.Object({
|
|
4603
5130
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4604
5131
|
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
4605
5132
|
}),
|
|
4606
5133
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4607
5134
|
const st = await requirePlan(ctx);
|
|
5135
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4608
5136
|
if (!st)
|
|
4609
5137
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4610
5138
|
const features = (await st.loadFeatures()).features;
|
|
4611
5139
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4612
5140
|
if (!found)
|
|
4613
5141
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4614
|
-
if (params.full)
|
|
4615
|
-
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
4616
5142
|
const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
4617
5143
|
if (!params.full)
|
|
4618
5144
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
@@ -4643,7 +5169,20 @@ export default function planPiExtension(pi) {
|
|
|
4643
5169
|
}
|
|
4644
5170
|
if (log)
|
|
4645
5171
|
sections.push(`Status log:\n${log}`);
|
|
4646
|
-
|
|
5172
|
+
sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
|
|
5173
|
+
const result = {
|
|
5174
|
+
content: [{ type: "text", text: sections.join("\n\n") }],
|
|
5175
|
+
details: {
|
|
5176
|
+
task: {
|
|
5177
|
+
ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5178
|
+
description: found.task.description,
|
|
5179
|
+
descriptionRef: found.task.descriptionRef ?? "",
|
|
5180
|
+
acceptedDecisions: found.task.acceptedDecisions,
|
|
5181
|
+
},
|
|
5182
|
+
},
|
|
5183
|
+
};
|
|
5184
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
5185
|
+
return result;
|
|
4647
5186
|
},
|
|
4648
5187
|
});
|
|
4649
5188
|
pi.registerTool({
|
|
@@ -4721,6 +5260,7 @@ export default function planPiExtension(pi) {
|
|
|
4721
5260
|
pauseHistory: [],
|
|
4722
5261
|
startedAt: "",
|
|
4723
5262
|
completedAt: "",
|
|
5263
|
+
activeOwnerSession: "",
|
|
4724
5264
|
createdAt: now, updatedAt: now,
|
|
4725
5265
|
};
|
|
4726
5266
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
@@ -4736,6 +5276,58 @@ export default function planPiExtension(pi) {
|
|
|
4736
5276
|
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
4737
5277
|
},
|
|
4738
5278
|
});
|
|
5279
|
+
pi.registerTool({
|
|
5280
|
+
name: "task_dependency_add",
|
|
5281
|
+
label: "Task Dependency Add",
|
|
5282
|
+
description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
|
|
5283
|
+
parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String() }),
|
|
5284
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5285
|
+
const st = await requirePlan(ctx);
|
|
5286
|
+
if (!st)
|
|
5287
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5288
|
+
const features = (await st.loadFeatures()).features;
|
|
5289
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
|
|
5290
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
|
|
5291
|
+
if (!found || !dependency)
|
|
5292
|
+
return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
|
|
5293
|
+
try {
|
|
5294
|
+
const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5295
|
+
await st.writeGenerated();
|
|
5296
|
+
return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
|
|
5297
|
+
}
|
|
5298
|
+
catch (error) {
|
|
5299
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5300
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency update failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }, isError: true };
|
|
5301
|
+
}
|
|
5302
|
+
},
|
|
5303
|
+
});
|
|
5304
|
+
pi.registerTool({
|
|
5305
|
+
name: "task_dependency_delete",
|
|
5306
|
+
label: "Task Dependency Delete",
|
|
5307
|
+
description: "Remove a task dependency atomically after explicit confirmation.",
|
|
5308
|
+
parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String(), confirmed: Type.Boolean() }),
|
|
5309
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5310
|
+
if (!params.confirmed)
|
|
5311
|
+
return { content: [{ type: "text", text: "Explicit confirmation is required." }], details: { updated: false, errorCode: "CONFIRMATION_REQUIRED" }, isError: true };
|
|
5312
|
+
const st = await requirePlan(ctx);
|
|
5313
|
+
if (!st)
|
|
5314
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5315
|
+
const features = (await st.loadFeatures()).features;
|
|
5316
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
|
|
5317
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
|
|
5318
|
+
if (!found || !dependency)
|
|
5319
|
+
return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
|
|
5320
|
+
try {
|
|
5321
|
+
const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5322
|
+
await st.writeGenerated();
|
|
5323
|
+
return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
|
|
5324
|
+
}
|
|
5325
|
+
catch (error) {
|
|
5326
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5327
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency deletion failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }, isError: true };
|
|
5328
|
+
}
|
|
5329
|
+
},
|
|
5330
|
+
});
|
|
4739
5331
|
pi.registerTool({
|
|
4740
5332
|
name: "task_update",
|
|
4741
5333
|
label: "Task Update",
|
|
@@ -4756,9 +5348,10 @@ export default function planPiExtension(pi) {
|
|
|
4756
5348
|
rationale: Type.String(),
|
|
4757
5349
|
implementationNotes: Type.String(),
|
|
4758
5350
|
acceptedAt: Type.String(),
|
|
4759
|
-
}), { description: "
|
|
5351
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4760
5352
|
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
4761
5353
|
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
5354
|
+
subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), title: Type.String(), description: Type.Optional(Type.String()), status: Type.Optional(Type.String()) }), { description: "Replace subtasks; existing IDs must belong to this task and omitted IDs are planner-generated." })),
|
|
4762
5355
|
}),
|
|
4763
5356
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4764
5357
|
const st = await requirePlan(ctx);
|
|
@@ -4787,6 +5380,13 @@ export default function planPiExtension(pi) {
|
|
|
4787
5380
|
details: {},
|
|
4788
5381
|
};
|
|
4789
5382
|
}
|
|
5383
|
+
if (params.acceptedDecisions !== undefined) {
|
|
5384
|
+
return {
|
|
5385
|
+
content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
|
|
5386
|
+
details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
|
|
5387
|
+
isError: true,
|
|
5388
|
+
};
|
|
5389
|
+
}
|
|
4790
5390
|
if (params.status !== undefined &&
|
|
4791
5391
|
params.status !== found.task.status &&
|
|
4792
5392
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4797,7 +5397,12 @@ export default function planPiExtension(pi) {
|
|
|
4797
5397
|
};
|
|
4798
5398
|
}
|
|
4799
5399
|
}
|
|
4800
|
-
|
|
5400
|
+
if (params.subtasks !== undefined) {
|
|
5401
|
+
const existingIds = new Set(found.task.subtasks.map((item) => item.id));
|
|
5402
|
+
if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
|
|
5403
|
+
return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
|
|
5404
|
+
}
|
|
5405
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
|
|
4801
5406
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4802
5407
|
if (receivedFields.length === 0) {
|
|
4803
5408
|
return mutationNoFieldsFailure({
|
|
@@ -4816,10 +5421,7 @@ export default function planPiExtension(pi) {
|
|
|
4816
5421
|
});
|
|
4817
5422
|
}
|
|
4818
5423
|
try {
|
|
4819
|
-
const updatedPhase = await st.
|
|
4820
|
-
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4821
|
-
if (!task)
|
|
4822
|
-
return phase;
|
|
5424
|
+
const updatedPhase = await st.updateTask(hostPhase.id, found.task.id, (task) => {
|
|
4823
5425
|
if (params.title !== undefined)
|
|
4824
5426
|
task.title = params.title;
|
|
4825
5427
|
if (params.priority !== undefined)
|
|
@@ -4837,8 +5439,6 @@ export default function planPiExtension(pi) {
|
|
|
4837
5439
|
task.notes = params.notes.trim();
|
|
4838
5440
|
if (params.decisions !== undefined)
|
|
4839
5441
|
task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
4840
|
-
if (params.acceptedDecisions !== undefined)
|
|
4841
|
-
task.acceptedDecisions = params.acceptedDecisions;
|
|
4842
5442
|
if (params.checklist !== undefined) {
|
|
4843
5443
|
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
4844
5444
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
@@ -4846,6 +5446,9 @@ export default function planPiExtension(pi) {
|
|
|
4846
5446
|
checked: false,
|
|
4847
5447
|
}));
|
|
4848
5448
|
}
|
|
5449
|
+
if (params.subtasks !== undefined) {
|
|
5450
|
+
task.subtasks = params.subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: (item.status ?? "planned"), createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? now, updatedAt: now }));
|
|
5451
|
+
}
|
|
4849
5452
|
if (params.status !== undefined) {
|
|
4850
5453
|
if (params.status !== task.status) {
|
|
4851
5454
|
const entry = {
|
|
@@ -4861,20 +5464,34 @@ export default function planPiExtension(pi) {
|
|
|
4861
5464
|
applyTaskLifecycleDates(task, params.status, now);
|
|
4862
5465
|
}
|
|
4863
5466
|
task.updatedAt = now;
|
|
4864
|
-
phase.updatedAt = now;
|
|
4865
5467
|
updatedTask = task;
|
|
4866
|
-
return
|
|
5468
|
+
return task;
|
|
4867
5469
|
});
|
|
4868
5470
|
void updatedPhase;
|
|
4869
5471
|
}
|
|
4870
5472
|
catch (e) {
|
|
4871
|
-
|
|
5473
|
+
const errorCode = e instanceof PlanStoreError ? e.details?.errorCode : undefined;
|
|
5474
|
+
return {
|
|
5475
|
+
content: [{ type: "text", text: `Update failed: ${e instanceof Error ? e.message : String(e)}` }],
|
|
5476
|
+
details: { updated: false, errorCode: errorCode ?? "TASK_UPDATE_FAILED" },
|
|
5477
|
+
isError: true,
|
|
5478
|
+
};
|
|
4872
5479
|
}
|
|
4873
5480
|
if (!updatedTask)
|
|
4874
|
-
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
5481
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { updated: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
|
|
4875
5482
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4876
5483
|
await st.writeGenerated();
|
|
4877
|
-
|
|
5484
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
5485
|
+
? await st.previewDescriptionReconciliation()
|
|
5486
|
+
: null;
|
|
5487
|
+
const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
|
|
5488
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
5489
|
+
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
5490
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
5491
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
5492
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
|
|
5493
|
+
: "";
|
|
5494
|
+
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
|
|
4878
5495
|
},
|
|
4879
5496
|
});
|
|
4880
5497
|
pi.registerTool({
|
|
@@ -4906,21 +5523,38 @@ export default function planPiExtension(pi) {
|
|
|
4906
5523
|
pi.registerTool({
|
|
4907
5524
|
name: "task_recommend",
|
|
4908
5525
|
label: "Task Recommend",
|
|
4909
|
-
description: "Return the
|
|
5526
|
+
description: "Return the next-work chain: active task if present, otherwise the lowest-priority ready feature → phase → task, plus bounded typed claims. Only persisted resume-ready handoffs are actionable; Markdown prose and terminal archives are not authority. Never blocks an explicit user choice.",
|
|
4910
5527
|
parameters: Type.Object({}),
|
|
4911
5528
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4912
5529
|
const st = await requirePlan(ctx);
|
|
4913
5530
|
if (!st)
|
|
4914
5531
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4915
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
5532
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
5533
|
+
st.loadFeatures(),
|
|
5534
|
+
st.loadAllPhases(),
|
|
5535
|
+
st.loadProject(),
|
|
5536
|
+
st.loadResume(),
|
|
5537
|
+
st.listHandoffs(),
|
|
5538
|
+
st.listArchivedHandoffs(),
|
|
5539
|
+
]);
|
|
5540
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
5541
|
+
handoffs: handoffs.map((handoff) => ({
|
|
5542
|
+
compositeRef: handoff.compositeRef,
|
|
5543
|
+
firstLine: handoff.firstLine,
|
|
5544
|
+
resumeReady: handoff.resumeReady,
|
|
5545
|
+
})),
|
|
5546
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
5547
|
+
});
|
|
5548
|
+
const { selection } = result;
|
|
5549
|
+
if (!selection.candidate)
|
|
5550
|
+
return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
|
|
5551
|
+
const { candidate } = selection;
|
|
4920
5552
|
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
5553
|
+
const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
5554
|
+
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
4921
5555
|
return {
|
|
4922
|
-
content: [{ type: "text", text:
|
|
4923
|
-
details: result,
|
|
5556
|
+
content: [{ type: "text", text: `${activeLine}Next work (${selection.kind}): ${reference} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}` }],
|
|
5557
|
+
details: { ...selection, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims },
|
|
4924
5558
|
};
|
|
4925
5559
|
},
|
|
4926
5560
|
});
|
|
@@ -5035,6 +5669,7 @@ export default function planPiExtension(pi) {
|
|
|
5035
5669
|
}),
|
|
5036
5670
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5037
5671
|
const st = await requirePlan(ctx);
|
|
5672
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
5038
5673
|
if (!st)
|
|
5039
5674
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5040
5675
|
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
@@ -5050,6 +5685,9 @@ export default function planPiExtension(pi) {
|
|
|
5050
5685
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
5051
5686
|
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
5052
5687
|
}
|
|
5688
|
+
if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
|
|
5689
|
+
return { content: [{ type: "text", text: `Task switch denied: source task is owned by another session.` }], details: {} };
|
|
5690
|
+
}
|
|
5053
5691
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
5054
5692
|
const linkedRequirements = [
|
|
5055
5693
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -5068,7 +5706,8 @@ export default function planPiExtension(pi) {
|
|
|
5068
5706
|
requirementIds: linkedRequirementIds,
|
|
5069
5707
|
};
|
|
5070
5708
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
5071
|
-
const
|
|
5709
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5710
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
5072
5711
|
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5073
5712
|
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
5074
5713
|
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
@@ -5086,7 +5725,7 @@ export default function planPiExtension(pi) {
|
|
|
5086
5725
|
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
|
|
5087
5726
|
}
|
|
5088
5727
|
if (!requirementsReady) {
|
|
5089
|
-
return { content: [{ type: "text", text:
|
|
5728
|
+
return { content: [{ type: "text", text: `Task switch denied: ${requirementEligibility.reason}` }], details: { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`requirement_list with phaseRef=${targetPhaseRef}`, `Retry task_switch to ${targetTaskRef}`] } };
|
|
5090
5729
|
}
|
|
5091
5730
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
5092
5731
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -5100,7 +5739,7 @@ export default function planPiExtension(pi) {
|
|
|
5100
5739
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
5101
5740
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
5102
5741
|
};
|
|
5103
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
5742
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
5104
5743
|
const record = {
|
|
5105
5744
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
5106
5745
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
@@ -5111,75 +5750,78 @@ export default function planPiExtension(pi) {
|
|
|
5111
5750
|
const sourceWasActive = source.task.status === "in-progress";
|
|
5112
5751
|
let sourceCheckpointed = false;
|
|
5113
5752
|
let targetStarted = false;
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
5121
|
-
if (!task)
|
|
5122
|
-
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
5123
|
-
task.pauseSnapshot = snapshot;
|
|
5124
|
-
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
5125
|
-
task.updatedAt = timestamp;
|
|
5126
|
-
return phase;
|
|
5127
|
-
});
|
|
5128
|
-
}
|
|
5129
|
-
sourceCheckpointed = true;
|
|
5130
|
-
if (target.task.pauseSnapshot) {
|
|
5131
|
-
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
5132
|
-
}
|
|
5133
|
-
else {
|
|
5134
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
5135
|
-
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
5136
|
-
if (!task)
|
|
5137
|
-
throw new Error(`Temporary task disappeared: ${params.to_task}`);
|
|
5138
|
-
const previousStatus = task.status;
|
|
5139
|
-
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
5140
|
-
task.statusLog = [...task.statusLog, {
|
|
5141
|
-
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
5142
|
-
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
5143
|
-
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
5144
|
-
}];
|
|
5145
|
-
task.updatedAt = timestamp;
|
|
5146
|
-
phase.updatedAt = timestamp;
|
|
5147
|
-
return phase;
|
|
5148
|
-
});
|
|
5149
|
-
}
|
|
5150
|
-
targetStarted = true;
|
|
5151
|
-
await st.addWorkDeviation(record);
|
|
5152
|
-
if (target.task.pauseSnapshot) {
|
|
5153
|
-
const resumedDeviation = project.workDeviations
|
|
5154
|
-
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
5155
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
5156
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
5157
|
-
if (resumedDeviation)
|
|
5158
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
5159
|
-
}
|
|
5160
|
-
}
|
|
5161
|
-
catch (error) {
|
|
5162
|
-
if (targetStarted) {
|
|
5163
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
5164
|
-
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
5165
|
-
return phase;
|
|
5166
|
-
}).catch(() => { });
|
|
5167
|
-
}
|
|
5168
|
-
if (sourceCheckpointed) {
|
|
5169
|
-
if (sourceWasActive)
|
|
5170
|
-
await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
|
|
5171
|
-
else
|
|
5753
|
+
await st.runBatch(async () => {
|
|
5754
|
+
try {
|
|
5755
|
+
if (sourceWasActive) {
|
|
5756
|
+
await st.pauseTask(source.phase.id, source.task.id, snapshot);
|
|
5757
|
+
}
|
|
5758
|
+
else {
|
|
5172
5759
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
5173
|
-
|
|
5760
|
+
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
5761
|
+
if (!task)
|
|
5762
|
+
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
5763
|
+
task.pauseSnapshot = snapshot;
|
|
5764
|
+
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
5765
|
+
task.updatedAt = timestamp;
|
|
5766
|
+
return phase;
|
|
5767
|
+
});
|
|
5768
|
+
}
|
|
5769
|
+
sourceCheckpointed = true;
|
|
5770
|
+
if (target.task.pauseSnapshot) {
|
|
5771
|
+
await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
|
|
5772
|
+
}
|
|
5773
|
+
else {
|
|
5774
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
5775
|
+
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
5776
|
+
if (!task)
|
|
5777
|
+
throw new Error(`Temporary task disappeared: ${params.to_task}`);
|
|
5778
|
+
const previousStatus = task.status;
|
|
5779
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
5780
|
+
task.activeOwnerSession = plannerSessionId;
|
|
5781
|
+
task.statusLog = [...task.statusLog, {
|
|
5782
|
+
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
5783
|
+
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
5784
|
+
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
5785
|
+
}];
|
|
5786
|
+
task.updatedAt = timestamp;
|
|
5787
|
+
phase.updatedAt = timestamp;
|
|
5788
|
+
return phase;
|
|
5789
|
+
});
|
|
5790
|
+
}
|
|
5791
|
+
targetStarted = true;
|
|
5792
|
+
await st.addWorkDeviation(record);
|
|
5793
|
+
if (target.task.pauseSnapshot) {
|
|
5794
|
+
const resumedDeviation = project.workDeviations
|
|
5795
|
+
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
5796
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
5797
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
5798
|
+
if (resumedDeviation)
|
|
5799
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
5800
|
+
}
|
|
5801
|
+
}
|
|
5802
|
+
catch (error) {
|
|
5803
|
+
if (targetStarted) {
|
|
5804
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
5805
|
+
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
5174
5806
|
return phase;
|
|
5175
5807
|
}).catch(() => { });
|
|
5808
|
+
}
|
|
5809
|
+
if (sourceCheckpointed) {
|
|
5810
|
+
if (sourceWasActive)
|
|
5811
|
+
await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
|
|
5812
|
+
else
|
|
5813
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
5814
|
+
phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
|
|
5815
|
+
return phase;
|
|
5816
|
+
}).catch(() => { });
|
|
5817
|
+
}
|
|
5818
|
+
throw error;
|
|
5176
5819
|
}
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
await st.writeGenerated();
|
|
5820
|
+
await st.syncTaskStatusRollup(source.phase.id);
|
|
5821
|
+
if (target.phase.id !== source.phase.id)
|
|
5822
|
+
await st.syncTaskStatusRollup(target.phase.id);
|
|
5823
|
+
await st.writeGenerated();
|
|
5824
|
+
});
|
|
5183
5825
|
invalidateReads(plannerSessionId);
|
|
5184
5826
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
5185
5827
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
@@ -5194,6 +5836,31 @@ export default function planPiExtension(pi) {
|
|
|
5194
5836
|
};
|
|
5195
5837
|
},
|
|
5196
5838
|
});
|
|
5839
|
+
pi.registerTool({
|
|
5840
|
+
name: "task_reopen",
|
|
5841
|
+
label: "Task Reopen",
|
|
5842
|
+
description: "Atomically reopen a completed task. Requires confirmed=true, retains completion evidence, and never pauses another task.",
|
|
5843
|
+
parameters: Type.Object({ taskId: Type.String({ description: "Task reference" }), confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }) }),
|
|
5844
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5845
|
+
const st = await requirePlan(ctx);
|
|
5846
|
+
if (!st)
|
|
5847
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: { reopened: false, errorCode: "PLAN_NOT_FOUND" }, isError: true };
|
|
5848
|
+
const features = (await st.loadFeatures()).features;
|
|
5849
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
5850
|
+
if (!found)
|
|
5851
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { reopened: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
|
|
5852
|
+
try {
|
|
5853
|
+
const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed: params.confirmed, ownerSessionId: `pi:${ctx.sessionManager.getSessionId()}` });
|
|
5854
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
5855
|
+
await st.writeGenerated();
|
|
5856
|
+
return { content: [{ type: "text", text: `✅ Task reopened: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(reopened.number)} — ${reopened.title} (in-progress)` }], details: { reopened: true, task: reopened } };
|
|
5857
|
+
}
|
|
5858
|
+
catch (error) {
|
|
5859
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5860
|
+
return { content: [{ type: "text", text: `Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}` }], details: { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }, isError: true };
|
|
5861
|
+
}
|
|
5862
|
+
},
|
|
5863
|
+
});
|
|
5197
5864
|
pi.registerTool({
|
|
5198
5865
|
name: "task_start",
|
|
5199
5866
|
label: "Task Start",
|
|
@@ -5203,153 +5870,158 @@ export default function planPiExtension(pi) {
|
|
|
5203
5870
|
}),
|
|
5204
5871
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5205
5872
|
const st = await requirePlan(ctx);
|
|
5873
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
5206
5874
|
if (!st)
|
|
5207
5875
|
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
taskId: task.id,
|
|
5228
|
-
phaseId: found.phase.id,
|
|
5229
|
-
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5230
|
-
task,
|
|
5231
|
-
phase: found.phase,
|
|
5232
|
-
...(feature ? { feature } : {}),
|
|
5233
|
-
requirements: linkedRequirements,
|
|
5234
|
-
requirementIds: linkedRequirementIds,
|
|
5235
|
-
};
|
|
5236
|
-
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
5237
|
-
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5238
|
-
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5239
|
-
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
5240
|
-
if (!projectGuidelinesReady) {
|
|
5241
|
-
return taskStartFailure(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { projectGuidelinesReadState, contextEligibility });
|
|
5242
|
-
}
|
|
5243
|
-
if (!contextEligibility.eligible) {
|
|
5244
|
-
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
|
|
5245
|
-
}
|
|
5246
|
-
if (!requirementsReady) {
|
|
5247
|
-
return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["requirement_list", `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }));
|
|
5248
|
-
}
|
|
5249
|
-
if (!hasValidSessionAttestation(contextInput)) {
|
|
5250
|
-
await st.recordContextRead({
|
|
5876
|
+
return st.runBatch(async () => {
|
|
5877
|
+
const features = (await st.loadFeatures()).features;
|
|
5878
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
5879
|
+
if (!found)
|
|
5880
|
+
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
5881
|
+
const task = found.task;
|
|
5882
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
5883
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
5884
|
+
const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
5885
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
5886
|
+
if (task.status === "done")
|
|
5887
|
+
return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_reopen with taskId=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry task_start ${taskRef} only if reopening is not the intended operation.`], { taskId: task.id }));
|
|
5888
|
+
const project = await st.loadProject();
|
|
5889
|
+
const linkedRequirements = [
|
|
5890
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
5891
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
5892
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
5893
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
5894
|
+
const contextInput = {
|
|
5251
5895
|
sessionId: plannerSessionId,
|
|
5252
|
-
phaseId: found.phase.id,
|
|
5253
5896
|
taskId: task.id,
|
|
5897
|
+
phaseId: found.phase.id,
|
|
5254
5898
|
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5899
|
+
task,
|
|
5900
|
+
phase: found.phase,
|
|
5901
|
+
...(feature ? { feature } : {}),
|
|
5902
|
+
requirements: linkedRequirements,
|
|
5255
5903
|
requirementIds: linkedRequirementIds,
|
|
5256
|
-
}
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
const
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5904
|
+
};
|
|
5905
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
5906
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5907
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
5908
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5909
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
5910
|
+
if (!projectGuidelinesReady) {
|
|
5911
|
+
return taskStartFailure(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { projectGuidelinesReadState, contextEligibility });
|
|
5912
|
+
}
|
|
5913
|
+
if (!contextEligibility.eligible) {
|
|
5914
|
+
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
|
|
5915
|
+
}
|
|
5916
|
+
if (!requirementsReady) {
|
|
5917
|
+
return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", `Linked requirements were not read; the task remains unchanged. ${requirementEligibility.reason}`, [`requirement_list with phaseRef=${phaseRef}`, `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }), { requirementEligibility });
|
|
5918
|
+
}
|
|
5919
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
5920
|
+
await st.recordContextRead({
|
|
5921
|
+
sessionId: plannerSessionId,
|
|
5922
|
+
phaseId: found.phase.id,
|
|
5923
|
+
taskId: task.id,
|
|
5924
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5925
|
+
requirementIds: linkedRequirementIds,
|
|
5926
|
+
});
|
|
5927
|
+
}
|
|
5928
|
+
if (task.status === "in-progress") {
|
|
5929
|
+
const outcome = taskStartSucceeded(task.id, true);
|
|
5930
|
+
return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
|
|
5931
|
+
}
|
|
5932
|
+
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
5933
|
+
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
5934
|
+
if (!eligibility.eligible)
|
|
5935
|
+
return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
|
|
5936
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
5937
|
+
if (selection.kind === "conflict") {
|
|
5938
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
5939
|
+
}
|
|
5940
|
+
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
5941
|
+
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
5942
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use task_switch to snapshot and pause ${activeRef}.`, `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
5943
|
+
}
|
|
5944
|
+
let resumeProposal;
|
|
5945
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
5946
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
5947
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
5948
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
5949
|
+
// machine-readable for the host/agent.
|
|
5950
|
+
const resumeTask = selection.candidate;
|
|
5951
|
+
const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
|
|
5952
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
5953
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
5954
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
5955
|
+
?? null;
|
|
5956
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
5957
|
+
ref: resumeRef,
|
|
5958
|
+
title: resumeTask.task.title,
|
|
5959
|
+
taskId: resumeTask.task.id,
|
|
5960
|
+
phaseId: resumeTask.phase.id,
|
|
5961
|
+
snapshot: resumeSnapshot
|
|
5962
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
5963
|
+
: null,
|
|
5964
|
+
});
|
|
5965
|
+
}
|
|
5966
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
5967
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
5968
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
5969
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
5970
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
5971
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
5972
|
+
]);
|
|
5973
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
|
|
5974
|
+
const advisory = selection.candidate && selection.candidate.task.id !== task.id
|
|
5975
|
+
? selection.kind === "resume"
|
|
5976
|
+
? `\n\n${resumeProposal ? resumeProposal.text : `⚠️ Resume advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`}`
|
|
5977
|
+
: `\n\n⚠️ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
|
|
5978
|
+
: "";
|
|
5979
|
+
const now = nowISO();
|
|
5980
|
+
let startedTask;
|
|
5981
|
+
if (task.pauseSnapshot) {
|
|
5982
|
+
startedTask = await st.resumeTask(found.phase.id, found.task.id, now, plannerSessionId);
|
|
5983
|
+
}
|
|
5984
|
+
else {
|
|
5985
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
5986
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
5987
|
+
if (!t)
|
|
5988
|
+
return phase;
|
|
5989
|
+
const previousStatus = t.status;
|
|
5990
|
+
applyTaskLifecycleDates(t, "in-progress", now);
|
|
5991
|
+
t.activeOwnerSession = plannerSessionId;
|
|
5992
|
+
t.statusLog = [...t.statusLog, {
|
|
5993
|
+
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
5994
|
+
date: now, fromStatus: previousStatus, toStatus: "in-progress",
|
|
5995
|
+
title: `${previousStatus} → in-progress`, description: "",
|
|
5996
|
+
}];
|
|
5997
|
+
t.updatedAt = now;
|
|
5998
|
+
phase.updatedAt = now;
|
|
5999
|
+
startedTask = t;
|
|
5318
6000
|
return phase;
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
5344
|
-
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
5345
|
-
return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
|
|
5346
|
-
}
|
|
5347
|
-
const outcome = taskStartSucceeded(persistedTask.id);
|
|
5348
|
-
return {
|
|
5349
|
-
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
5350
|
-
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
5351
|
-
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
5352
|
-
};
|
|
6001
|
+
});
|
|
6002
|
+
}
|
|
6003
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
6004
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
|
|
6005
|
+
if (approvedDeviation)
|
|
6006
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", now);
|
|
6007
|
+
const resumedDeviation = project.workDeviations
|
|
6008
|
+
.filter((deviation) => deviation.resumeTaskId === task.id
|
|
6009
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
6010
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
6011
|
+
if (resumedDeviation)
|
|
6012
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
6013
|
+
await st.writeGenerated();
|
|
6014
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
6015
|
+
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
6016
|
+
return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
|
|
6017
|
+
}
|
|
6018
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
6019
|
+
return {
|
|
6020
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
6021
|
+
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
6022
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
6023
|
+
};
|
|
6024
|
+
});
|
|
5353
6025
|
},
|
|
5354
6026
|
});
|
|
5355
6027
|
pi.registerTool({
|
|
@@ -5772,12 +6444,15 @@ export default function planPiExtension(pi) {
|
|
|
5772
6444
|
const openQuestions = plan.phases
|
|
5773
6445
|
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
5774
6446
|
.slice(0, 8);
|
|
5775
|
-
const
|
|
5776
|
-
|
|
5777
|
-
...plan.features.features.
|
|
5778
|
-
...plan.phases.
|
|
5779
|
-
...plan.phases.flatMap((phase) => phase.tasks.
|
|
5780
|
-
|
|
6447
|
+
const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
|
|
6448
|
+
{ scope: "project", decisions: project.acceptedDecisions },
|
|
6449
|
+
...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
|
|
6450
|
+
...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
|
|
6451
|
+
...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
|
|
6452
|
+
scope: `task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
|
|
6453
|
+
decisions: task.acceptedDecisions,
|
|
6454
|
+
}))),
|
|
6455
|
+
]);
|
|
5781
6456
|
const webServer = server;
|
|
5782
6457
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
5783
6458
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -5850,8 +6525,7 @@ export default function planPiExtension(pi) {
|
|
|
5850
6525
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
5851
6526
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
5852
6527
|
"",
|
|
5853
|
-
|
|
5854
|
-
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
6528
|
+
acceptedDecisionContext.content,
|
|
5855
6529
|
openQuestions.length ? "" : "",
|
|
5856
6530
|
openQuestions.length ? "Open questions:" : "",
|
|
5857
6531
|
...openQuestions.map((question) => `- ${question}`),
|