@agent-plan/pi-adapter 0.2.26 → 0.2.27
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 +1338 -666
- package/package.json +3 -3
- package/web-ui-dist/assets/index-B3wQpFUr.css +1 -0
- package/web-ui-dist/assets/index-Db8IRPH8.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 read-back and source reconciliation completed."
|
|
3843
|
+
: "NOT resume-ready: after reading this entire persisted body, call handoff_verify with its contentHash and a fresh comparison against every required source. If any gap exists, rewrite instead of verifying.";
|
|
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 only after handoff_show has displayed the entire body and the agent has compared it again with conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer output. Any discovered omission blocks verification and requires prepare+rewrite.",
|
|
3854
|
+
parameters: Type.Object({
|
|
3855
|
+
phaseRef: Type.String(),
|
|
3856
|
+
expectedContentHash: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
3857
|
+
sourceReviews: 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.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
|
+
sourceReviews: params.sourceReviews,
|
|
3874
|
+
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: "Audit one exact phase before writing a handoff. Returns the current
|
|
3893
|
+
description: "Audit one exact phase before writing a handoff. Returns the canonical scaffold, a bounded priority-ordered phase work map with sibling goals/dependencies/capability ownership, the current token, missing task evidence, required audits, and the 24,000-character inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; reconcile everything through handoff_write.",
|
|
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);
|
|
@@ -3660,11 +3909,22 @@ 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
3921
|
`Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
|
|
3667
3922
|
...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3923
|
+
"",
|
|
3924
|
+
`Required cold-start source review v${audit.coldStartInventoryVersion} — inspect and substantively summarize every source before drafting:`,
|
|
3925
|
+
...audit.coldStartSourceReviews.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3926
|
+
"Required cold-start inventory — every category needs a concrete item (use an explicit verified-negative statement when nothing exists), and every item must appear verbatim in the handoff or a validated supporting document:",
|
|
3927
|
+
...audit.coldStartInventoryCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3668
3928
|
`Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
|
|
3669
3929
|
"",
|
|
3670
3930
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
@@ -3682,10 +3942,11 @@ export default function planPiExtension(pi) {
|
|
|
3682
3942
|
pi.registerTool({
|
|
3683
3943
|
name: "handoff_write",
|
|
3684
3944
|
label: "Handoff Write",
|
|
3685
|
-
description: "
|
|
3945
|
+
description: "Persist a reconciled handoff candidate with durable context synchronization and mandatory audits. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify source reconciliation succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
|
|
3686
3946
|
parameters: Type.Object({
|
|
3687
3947
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3688
3948
|
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3949
|
+
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
3950
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3690
3951
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3691
3952
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
@@ -3699,6 +3960,18 @@ export default function planPiExtension(pi) {
|
|
|
3699
3960
|
detail: Type.String({ description: "Substantive captured detail or a substantive reason why the category is not applicable." }),
|
|
3700
3961
|
})),
|
|
3701
3962
|
})),
|
|
3963
|
+
coldStartInventory: Type.Optional(Type.Object({
|
|
3964
|
+
version: Type.Literal(HANDOFF_COLD_START_INVENTORY_VERSION),
|
|
3965
|
+
sourceReviews: Type.Array(Type.Object({
|
|
3966
|
+
source: Type.String({ description: `One required source review id: ${HANDOFF_COLD_START_SOURCE_REVIEWS.map((entry) => entry.id).join(", ")}` }),
|
|
3967
|
+
detail: Type.String({ description: "Substantive summary of what was reviewed and which facts were extracted before drafting." }),
|
|
3968
|
+
})),
|
|
3969
|
+
entries: Type.Array(Type.Object({
|
|
3970
|
+
category: Type.String({ description: `One required cold-start category id from handoff_prepare: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
|
|
3971
|
+
items: Type.Array(Type.String({ description: "Exact resume-critical item; it must appear verbatim in the handoff or a validated supporting document." })),
|
|
3972
|
+
notApplicableReason: Type.Optional(Type.String({ description: "Optional explanation only; every category still requires a concrete item, using an explicit verified-negative statement when nothing exists." })),
|
|
3973
|
+
})),
|
|
3974
|
+
})),
|
|
3702
3975
|
supportingDocuments: Type.Optional(Type.Array(Type.Object({
|
|
3703
3976
|
path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
|
|
3704
3977
|
description: Type.String({ description: "What the document contains and why the next agent needs it." }),
|
|
@@ -3749,7 +4022,9 @@ export default function planPiExtension(pi) {
|
|
|
3749
4022
|
if (!params.confirmed)
|
|
3750
4023
|
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
4024
|
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3752
|
-
return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: {
|
|
4025
|
+
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 } };
|
|
4026
|
+
if (!params.reason?.trim())
|
|
4027
|
+
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
4028
|
const phases = await st.loadAllPhases();
|
|
3754
4029
|
const features = (await st.loadFeatures()).features;
|
|
3755
4030
|
const taskUpdates = [];
|
|
@@ -3768,10 +4043,12 @@ export default function planPiExtension(pi) {
|
|
|
3768
4043
|
}
|
|
3769
4044
|
try {
|
|
3770
4045
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
4046
|
+
reason: params.reason.trim(),
|
|
3771
4047
|
content: body,
|
|
3772
4048
|
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3773
4049
|
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3774
4050
|
...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
|
|
4051
|
+
...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
|
|
3775
4052
|
...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
|
|
3776
4053
|
contextSync: {
|
|
3777
4054
|
taskUpdates,
|
|
@@ -3787,13 +4064,21 @@ export default function planPiExtension(pi) {
|
|
|
3787
4064
|
});
|
|
3788
4065
|
await st.writeGenerated();
|
|
3789
4066
|
return {
|
|
3790
|
-
content: [{ type: "text", text:
|
|
4067
|
+
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 entire persisted body, compare it again with every required source, then call handoff_verify with the returned contentHash. If that review finds any gap, run prepare+write again instead of verifying.` }],
|
|
3791
4068
|
details: {
|
|
3792
4069
|
phaseRef: r.compositeRef,
|
|
3793
4070
|
phaseId: r.phase.id,
|
|
3794
4071
|
updatedTaskIds: result.updatedTaskIds,
|
|
3795
4072
|
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
3796
4073
|
handoffAudit: result.handoffAudit,
|
|
4074
|
+
persisted: true,
|
|
4075
|
+
resumeReady: false,
|
|
4076
|
+
verificationRequired: true,
|
|
4077
|
+
nextActions: [
|
|
4078
|
+
`Call handoff_show ${r.compositeRef} and read the entire persisted body.`,
|
|
4079
|
+
"Compare the persisted body against conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer-agent output.",
|
|
4080
|
+
"If any omission exists, run handoff_prepare and handoff_write again; otherwise call handoff_verify with the shown contentHash.",
|
|
4081
|
+
],
|
|
3797
4082
|
},
|
|
3798
4083
|
};
|
|
3799
4084
|
}
|
|
@@ -3875,13 +4160,14 @@ export default function planPiExtension(pi) {
|
|
|
3875
4160
|
pi.registerTool({
|
|
3876
4161
|
name: "feature_get",
|
|
3877
4162
|
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.",
|
|
4163
|
+
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
4164
|
parameters: Type.Object({
|
|
3880
4165
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3881
4166
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
3882
4167
|
}),
|
|
3883
4168
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3884
4169
|
const st = await requirePlan(ctx);
|
|
4170
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
3885
4171
|
if (!st)
|
|
3886
4172
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3887
4173
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3890,19 +4176,30 @@ export default function planPiExtension(pi) {
|
|
|
3890
4176
|
if (!resolvedFeature.ok)
|
|
3891
4177
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3892
4178
|
const feature = resolvedFeature.feature;
|
|
3893
|
-
if (params.full)
|
|
3894
|
-
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3895
4179
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3896
4180
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3897
4181
|
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
4182
|
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
3899
4183
|
const requirementsBlock = linkedRequirements.length > 0
|
|
3900
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4184
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
3901
4185
|
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3902
|
-
|
|
3903
|
-
content: [{ type: "text", text:
|
|
3904
|
-
|
|
4186
|
+
if (!params.full)
|
|
4187
|
+
return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
|
|
4188
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
|
|
4189
|
+
const result = {
|
|
4190
|
+
content: [{ type: "text", text: `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
|
|
4191
|
+
details: {
|
|
4192
|
+
feature: {
|
|
4193
|
+
ref: formatFeatureRef(feature.number),
|
|
4194
|
+
description: feature.description,
|
|
4195
|
+
descriptionRef: feature.descriptionRef ?? "",
|
|
4196
|
+
acceptedDecisions: feature.acceptedDecisions,
|
|
4197
|
+
},
|
|
4198
|
+
linkedRequirements,
|
|
4199
|
+
},
|
|
3905
4200
|
};
|
|
4201
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
|
|
4202
|
+
return result;
|
|
3906
4203
|
},
|
|
3907
4204
|
});
|
|
3908
4205
|
pi.registerTool({
|
|
@@ -3918,44 +4215,46 @@ export default function planPiExtension(pi) {
|
|
|
3918
4215
|
const st = await requirePlan(ctx);
|
|
3919
4216
|
if (!st)
|
|
3920
4217
|
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
|
-
|
|
4218
|
+
return st.runBatch(async () => {
|
|
4219
|
+
const now = nowISO();
|
|
4220
|
+
const status = params.status ?? "planned";
|
|
4221
|
+
const currentFeatures = (await st.loadFeatures()).features;
|
|
4222
|
+
const id = createFeatureId();
|
|
4223
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
4224
|
+
const priority = await st.nextPriority("feature");
|
|
4225
|
+
const feature = {
|
|
4226
|
+
id,
|
|
4227
|
+
number: identity.number,
|
|
4228
|
+
shortId: identity.shortId,
|
|
4229
|
+
priority,
|
|
4230
|
+
name: params.name,
|
|
4231
|
+
description: params.description ?? "",
|
|
4232
|
+
descriptionUpdatedAt: now,
|
|
4233
|
+
status,
|
|
4234
|
+
discussedAt: "",
|
|
4235
|
+
contextReady: false,
|
|
4236
|
+
contextReadyReason: "",
|
|
4237
|
+
startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
|
|
4238
|
+
endDate: "",
|
|
4239
|
+
workDone: "",
|
|
4240
|
+
workRemaining: "",
|
|
4241
|
+
acceptedDecisions: [],
|
|
4242
|
+
phaseIds: [],
|
|
4243
|
+
dependsOn: [],
|
|
4244
|
+
statusLog: [],
|
|
4245
|
+
sessionInfo: [],
|
|
4246
|
+
createdAt: now,
|
|
4247
|
+
updatedAt: now,
|
|
4248
|
+
};
|
|
4249
|
+
// Atomic read-modify-write: serializes concurrent feature_create calls
|
|
4250
|
+
// (batch) so they don't overwrite each other (last-write-wins race).
|
|
4251
|
+
await st.updateFeatures((doc) => {
|
|
4252
|
+
doc.features.push(feature);
|
|
4253
|
+
return doc;
|
|
4254
|
+
});
|
|
4255
|
+
await st.writeGenerated();
|
|
4256
|
+
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3956
4257
|
});
|
|
3957
|
-
await st.writeGenerated();
|
|
3958
|
-
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3959
4258
|
},
|
|
3960
4259
|
});
|
|
3961
4260
|
pi.registerTool({
|
|
@@ -4032,13 +4331,13 @@ export default function planPiExtension(pi) {
|
|
|
4032
4331
|
pi.registerTool({
|
|
4033
4332
|
name: "feature_update",
|
|
4034
4333
|
label: "Feature Update",
|
|
4035
|
-
description: "Update one or more
|
|
4334
|
+
description: "Update one or more canonical feature fields. Feature status is derived from child phases/tasks and cannot be changed directly.",
|
|
4036
4335
|
parameters: Type.Object({
|
|
4037
4336
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4038
4337
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
4039
4338
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
4040
4339
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4041
|
-
status: Type.Optional(Type.String({ description: "
|
|
4340
|
+
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
4341
|
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
4043
4342
|
endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
4044
4343
|
workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
|
|
@@ -4051,7 +4350,7 @@ export default function planPiExtension(pi) {
|
|
|
4051
4350
|
rationale: Type.String(),
|
|
4052
4351
|
implementationNotes: Type.String(),
|
|
4053
4352
|
acceptedAt: Type.String(),
|
|
4054
|
-
}), { description: "
|
|
4353
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4055
4354
|
}),
|
|
4056
4355
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4057
4356
|
const st = await requirePlan(ctx);
|
|
@@ -4065,6 +4364,13 @@ export default function planPiExtension(pi) {
|
|
|
4065
4364
|
if (params.status !== undefined) {
|
|
4066
4365
|
return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
4067
4366
|
}
|
|
4367
|
+
if (params.acceptedDecisions !== undefined) {
|
|
4368
|
+
return {
|
|
4369
|
+
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." }],
|
|
4370
|
+
details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
|
|
4371
|
+
isError: true,
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4068
4374
|
const mutableFields = [
|
|
4069
4375
|
"name",
|
|
4070
4376
|
"description",
|
|
@@ -4074,7 +4380,6 @@ export default function planPiExtension(pi) {
|
|
|
4074
4380
|
"workDone",
|
|
4075
4381
|
"workRemaining",
|
|
4076
4382
|
"priority",
|
|
4077
|
-
"acceptedDecisions",
|
|
4078
4383
|
];
|
|
4079
4384
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4080
4385
|
if (receivedFields.length === 0) {
|
|
@@ -4120,8 +4425,6 @@ export default function planPiExtension(pi) {
|
|
|
4120
4425
|
feature.endDate = params.endDate.trim();
|
|
4121
4426
|
if (params.priority !== undefined)
|
|
4122
4427
|
feature.priority = params.priority;
|
|
4123
|
-
if (params.acceptedDecisions !== undefined)
|
|
4124
|
-
feature.acceptedDecisions = params.acceptedDecisions;
|
|
4125
4428
|
feature.updatedAt = nowISO();
|
|
4126
4429
|
return doc;
|
|
4127
4430
|
});
|
|
@@ -4160,22 +4463,24 @@ export default function planPiExtension(pi) {
|
|
|
4160
4463
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4161
4464
|
const featureId = resolvedFeature.feature.id;
|
|
4162
4465
|
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
4466
|
let cascadeCount = 0;
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4467
|
+
await st.runBatch(async () => {
|
|
4468
|
+
await st.updateFeatures((doc) => {
|
|
4469
|
+
const before = doc.features.length;
|
|
4470
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
4471
|
+
if (doc.features.length !== before)
|
|
4472
|
+
deleted = true;
|
|
4473
|
+
return doc;
|
|
4474
|
+
});
|
|
4475
|
+
if (params.cascade) {
|
|
4476
|
+
const phases = await st.loadAllPhases();
|
|
4477
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
4478
|
+
await st.deletePhase(phase.id);
|
|
4479
|
+
cascadeCount += 1;
|
|
4480
|
+
}
|
|
4176
4481
|
}
|
|
4177
|
-
|
|
4178
|
-
|
|
4482
|
+
await st.writeGenerated();
|
|
4483
|
+
});
|
|
4179
4484
|
return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
|
|
4180
4485
|
},
|
|
4181
4486
|
});
|
|
@@ -4214,32 +4519,44 @@ export default function planPiExtension(pi) {
|
|
|
4214
4519
|
pi.registerTool({
|
|
4215
4520
|
name: "phase_get",
|
|
4216
4521
|
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.",
|
|
4522
|
+
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
4523
|
parameters: Type.Object({
|
|
4219
4524
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4220
4525
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
4221
4526
|
}),
|
|
4222
4527
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4223
4528
|
const st = await requirePlan(ctx);
|
|
4529
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4224
4530
|
if (!st)
|
|
4225
4531
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4226
4532
|
const features = (await st.loadFeatures()).features;
|
|
4227
4533
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
4228
4534
|
if (!phase)
|
|
4229
4535
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4230
|
-
if (params.full)
|
|
4231
|
-
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
4232
4536
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
4233
4537
|
const reqCount = linkedRequirements.length;
|
|
4234
4538
|
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
4539
|
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
4236
4540
|
const requirementsBlock = reqCount > 0
|
|
4237
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4541
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
4238
4542
|
: "";
|
|
4239
|
-
|
|
4240
|
-
content: [{ type: "text", text:
|
|
4241
|
-
|
|
4543
|
+
if (!params.full)
|
|
4544
|
+
return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
|
|
4545
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
|
|
4546
|
+
const result = {
|
|
4547
|
+
content: [{ type: "text", text: `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
|
|
4548
|
+
details: {
|
|
4549
|
+
phase: {
|
|
4550
|
+
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
4551
|
+
description: phase.description,
|
|
4552
|
+
descriptionRef: phase.descriptionRef ?? "",
|
|
4553
|
+
acceptedDecisions: phase.acceptedDecisions,
|
|
4554
|
+
},
|
|
4555
|
+
linkedRequirements,
|
|
4556
|
+
},
|
|
4242
4557
|
};
|
|
4558
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
|
|
4559
|
+
return result;
|
|
4243
4560
|
},
|
|
4244
4561
|
});
|
|
4245
4562
|
pi.registerTool({
|
|
@@ -4274,7 +4591,7 @@ export default function planPiExtension(pi) {
|
|
|
4274
4591
|
if (!existingFeature)
|
|
4275
4592
|
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
4276
4593
|
let phase;
|
|
4277
|
-
await withFeatureLock(featureId, async () => {
|
|
4594
|
+
await st.runBatch(() => withFeatureLock(featureId, async () => {
|
|
4278
4595
|
const id = createPhaseId();
|
|
4279
4596
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
4280
4597
|
const priority = await st.nextPriority("phase", featureId);
|
|
@@ -4307,7 +4624,7 @@ export default function planPiExtension(pi) {
|
|
|
4307
4624
|
return doc;
|
|
4308
4625
|
});
|
|
4309
4626
|
await st.writeGenerated();
|
|
4310
|
-
});
|
|
4627
|
+
}));
|
|
4311
4628
|
if (!phase)
|
|
4312
4629
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
4313
4630
|
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
@@ -4315,19 +4632,99 @@ export default function planPiExtension(pi) {
|
|
|
4315
4632
|
},
|
|
4316
4633
|
});
|
|
4317
4634
|
pi.registerTool({
|
|
4318
|
-
name: "
|
|
4319
|
-
label: "Phase
|
|
4320
|
-
description: "
|
|
4635
|
+
name: "phase_discuss",
|
|
4636
|
+
label: "Phase Discuss",
|
|
4637
|
+
description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
|
|
4321
4638
|
parameters: Type.Object({
|
|
4322
4639
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
summary: Type.Optional(Type.String(
|
|
4326
|
-
|
|
4640
|
+
goal: Type.Optional(Type.String({ description: "Backward-compatible single main goal" })),
|
|
4641
|
+
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace the phase goals list" })),
|
|
4642
|
+
summary: Type.Optional(Type.String()),
|
|
4643
|
+
scope: Type.Optional(Type.String({ description: "Current phase scope; stored as the phase description" })),
|
|
4327
4644
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4645
|
+
nonGoals: Type.Optional(Type.Array(Type.String())),
|
|
4646
|
+
dependencies: Type.Optional(Type.Array(Type.String())),
|
|
4647
|
+
risks: Type.Optional(Type.Array(Type.String())),
|
|
4648
|
+
openQuestions: Type.Optional(Type.Array(Type.String())),
|
|
4649
|
+
completionCriteria: Type.Optional(Type.Array(Type.String())),
|
|
4650
|
+
}),
|
|
4651
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4652
|
+
const st = await requirePlan(ctx);
|
|
4653
|
+
if (!st)
|
|
4654
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4655
|
+
const features = (await st.loadFeatures()).features;
|
|
4656
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
4657
|
+
if (!resolvedPhase)
|
|
4658
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: { discussed: false, errorCode: "PHASE_NOT_FOUND" }, isError: true };
|
|
4659
|
+
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4660
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4661
|
+
if (receivedFields.length === 0) {
|
|
4662
|
+
return mutationNoFieldsFailure({
|
|
4663
|
+
entity: "phase",
|
|
4664
|
+
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4665
|
+
operation: "discuss",
|
|
4666
|
+
mutableFields,
|
|
4667
|
+
retryCommand: `phase_discuss ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
|
|
4668
|
+
readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
|
|
4669
|
+
descriptionFallback: {
|
|
4670
|
+
entityId: resolvedPhase.id,
|
|
4671
|
+
inputField: "scope",
|
|
4672
|
+
storedField: "phase description",
|
|
4673
|
+
descriptionRefField: "descriptionRef",
|
|
4674
|
+
},
|
|
4675
|
+
});
|
|
4676
|
+
}
|
|
4677
|
+
const phase = await st.updatePhase(resolvedPhase.id, (current) => {
|
|
4678
|
+
if (params.goals !== undefined)
|
|
4679
|
+
current.goals = params.goals.map((item) => item.trim()).filter(Boolean);
|
|
4680
|
+
else if (params.goal !== undefined)
|
|
4681
|
+
current.goals = [params.goal.trim()].filter(Boolean);
|
|
4682
|
+
if (params.summary !== undefined)
|
|
4683
|
+
current.summary = params.summary.trim();
|
|
4684
|
+
if (params.scope !== undefined)
|
|
4685
|
+
current.description = params.scope.trim();
|
|
4686
|
+
if (params.descriptionRef !== undefined) {
|
|
4687
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4688
|
+
if (descriptionRef)
|
|
4689
|
+
current.descriptionRef = descriptionRef;
|
|
4690
|
+
else
|
|
4691
|
+
delete current.descriptionRef;
|
|
4692
|
+
}
|
|
4693
|
+
if (params.nonGoals !== undefined)
|
|
4694
|
+
current.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
4695
|
+
if (params.dependencies !== undefined)
|
|
4696
|
+
current.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
4697
|
+
if (params.risks !== undefined)
|
|
4698
|
+
current.risks = params.risks.map((item) => item.trim()).filter(Boolean);
|
|
4699
|
+
if (params.openQuestions !== undefined)
|
|
4700
|
+
current.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
|
|
4701
|
+
if (params.completionCriteria !== undefined)
|
|
4702
|
+
current.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
4703
|
+
current.discussedAt = nowISO();
|
|
4704
|
+
current.contextReady = true;
|
|
4705
|
+
current.contextReadyReason = "Updated through phase_discuss tool.";
|
|
4706
|
+
current.updatedAt = nowISO();
|
|
4707
|
+
return current;
|
|
4708
|
+
});
|
|
4709
|
+
await st.writeGenerated();
|
|
4710
|
+
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
4711
|
+
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 } };
|
|
4712
|
+
},
|
|
4713
|
+
});
|
|
4714
|
+
pi.registerTool({
|
|
4715
|
+
name: "phase_update",
|
|
4716
|
+
label: "Phase Update",
|
|
4717
|
+
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.",
|
|
4718
|
+
parameters: Type.Object({
|
|
4719
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
4720
|
+
title: Type.Optional(Type.String({ description: "New title" })),
|
|
4721
|
+
status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
|
|
4722
|
+
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
4723
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
4724
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4725
|
+
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
4726
|
+
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
4727
|
+
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
|
|
4331
4728
|
nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
|
|
4332
4729
|
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
|
|
4333
4730
|
risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
|
|
@@ -4340,7 +4737,7 @@ export default function planPiExtension(pi) {
|
|
|
4340
4737
|
rationale: Type.String(),
|
|
4341
4738
|
implementationNotes: Type.String(),
|
|
4342
4739
|
acceptedAt: Type.String(),
|
|
4343
|
-
}), { description: "
|
|
4740
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4344
4741
|
completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
|
|
4345
4742
|
}),
|
|
4346
4743
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -4362,7 +4759,14 @@ export default function planPiExtension(pi) {
|
|
|
4362
4759
|
if (params.status !== undefined) {
|
|
4363
4760
|
return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
4364
4761
|
}
|
|
4365
|
-
|
|
4762
|
+
if (params.acceptedDecisions !== undefined) {
|
|
4763
|
+
return {
|
|
4764
|
+
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." }],
|
|
4765
|
+
details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4766
|
+
isError: true,
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
|
|
4366
4770
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4367
4771
|
if (receivedFields.length === 0) {
|
|
4368
4772
|
return mutationNoFieldsFailure({
|
|
@@ -4382,39 +4786,6 @@ export default function planPiExtension(pi) {
|
|
|
4382
4786
|
}
|
|
4383
4787
|
const prevFeatureId = phase.featureId;
|
|
4384
4788
|
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
4789
|
if (params.featureId !== undefined) {
|
|
4419
4790
|
if (params.featureId === "") {
|
|
4420
4791
|
nextFeatureIdForUpdate = undefined;
|
|
@@ -4425,24 +4796,60 @@ export default function planPiExtension(pi) {
|
|
|
4425
4796
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4426
4797
|
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
4427
4798
|
}
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4799
|
+
}
|
|
4800
|
+
let persistedPhase;
|
|
4801
|
+
await st.runBatch(async () => {
|
|
4802
|
+
persistedPhase = await st.updatePhase(phaseId, (current) => {
|
|
4803
|
+
const next = {
|
|
4804
|
+
...current,
|
|
4805
|
+
...(params.title !== undefined ? { title: params.title.trim(), slug: normalizeSlug(params.title) } : {}),
|
|
4806
|
+
...(params.summary !== undefined ? { summary: params.summary.trim() } : {}),
|
|
4807
|
+
...(params.description !== undefined ? { description: params.description.trim() } : {}),
|
|
4808
|
+
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
|
4809
|
+
...(params.goals !== undefined ? { goals: params.goals.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4810
|
+
...(params.nonGoals !== undefined ? { nonGoals: params.nonGoals.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4811
|
+
...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4812
|
+
...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4813
|
+
...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4814
|
+
...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4815
|
+
...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4816
|
+
...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
|
|
4817
|
+
updatedAt: nowISO(),
|
|
4818
|
+
};
|
|
4819
|
+
if (params.descriptionRef !== undefined) {
|
|
4820
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4821
|
+
if (descriptionRef)
|
|
4822
|
+
next.descriptionRef = descriptionRef;
|
|
4823
|
+
else
|
|
4824
|
+
delete next.descriptionRef;
|
|
4825
|
+
}
|
|
4826
|
+
return next;
|
|
4827
|
+
});
|
|
4828
|
+
if (params.featureId !== undefined && nextFeatureIdForUpdate !== prevFeatureId) {
|
|
4829
|
+
await st.updateFeatures((document) => {
|
|
4830
|
+
for (const feature of document.features) {
|
|
4831
|
+
if (feature.id === prevFeatureId)
|
|
4832
|
+
feature.phaseIds = feature.phaseIds.filter((id) => id !== phaseId);
|
|
4833
|
+
if (feature.id === nextFeatureIdForUpdate && !feature.phaseIds.includes(phaseId))
|
|
4834
|
+
feature.phaseIds.push(phaseId);
|
|
4436
4835
|
}
|
|
4437
|
-
return
|
|
4836
|
+
return document;
|
|
4438
4837
|
});
|
|
4439
4838
|
}
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
await st.writeGenerated();
|
|
4839
|
+
await st.writeGenerated();
|
|
4840
|
+
});
|
|
4841
|
+
phase = persistedPhase;
|
|
4444
4842
|
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
4445
|
-
|
|
4843
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
4844
|
+
? await st.previewDescriptionReconciliation()
|
|
4845
|
+
: null;
|
|
4846
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
4847
|
+
.filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
|
|
4848
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
4849
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
4850
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
|
|
4851
|
+
: "";
|
|
4852
|
+
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
4853
|
},
|
|
4447
4854
|
});
|
|
4448
4855
|
pi.registerTool({
|
|
@@ -4480,7 +4887,7 @@ export default function planPiExtension(pi) {
|
|
|
4480
4887
|
implementationNotes: params.implementationNotes.trim(),
|
|
4481
4888
|
acceptedAt,
|
|
4482
4889
|
};
|
|
4483
|
-
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
4890
|
+
await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
4484
4891
|
let featureWritten = false;
|
|
4485
4892
|
try {
|
|
4486
4893
|
await st.updateFeatures((doc) => {
|
|
@@ -4511,11 +4918,128 @@ export default function planPiExtension(pi) {
|
|
|
4511
4918
|
}
|
|
4512
4919
|
throw error;
|
|
4513
4920
|
}
|
|
4514
|
-
});
|
|
4921
|
+
}));
|
|
4515
4922
|
await st.writeGenerated();
|
|
4516
4923
|
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
4517
4924
|
},
|
|
4518
4925
|
});
|
|
4926
|
+
pi.registerTool({
|
|
4927
|
+
name: "accepted_decision_create",
|
|
4928
|
+
label: "Accepted Decision Create",
|
|
4929
|
+
description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
|
|
4930
|
+
parameters: Type.Object({
|
|
4931
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
4932
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
4933
|
+
title: Type.String({ minLength: 1 }),
|
|
4934
|
+
decision: Type.Optional(Type.String()),
|
|
4935
|
+
rationale: Type.Optional(Type.String()),
|
|
4936
|
+
implementationNotes: Type.Optional(Type.String()),
|
|
4937
|
+
}),
|
|
4938
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4939
|
+
const st = await requirePlan(ctx);
|
|
4940
|
+
if (!st)
|
|
4941
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4942
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
4943
|
+
if (!target.ok)
|
|
4944
|
+
return { content: [{ type: "text", text: target.error }], details: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
4945
|
+
const createInput = {
|
|
4946
|
+
title: params.title,
|
|
4947
|
+
...(params.decision !== undefined ? { decision: params.decision } : {}),
|
|
4948
|
+
...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
|
|
4949
|
+
...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
|
|
4950
|
+
};
|
|
4951
|
+
try {
|
|
4952
|
+
const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
|
|
4953
|
+
await st.writeGenerated();
|
|
4954
|
+
return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
|
|
4955
|
+
}
|
|
4956
|
+
catch (error) {
|
|
4957
|
+
return acceptedDecisionMutationFailure(error, "created");
|
|
4958
|
+
}
|
|
4959
|
+
},
|
|
4960
|
+
});
|
|
4961
|
+
pi.registerTool({
|
|
4962
|
+
name: "accepted_decision_update",
|
|
4963
|
+
label: "Accepted Decision Update",
|
|
4964
|
+
description: "Update one accepted decision while preserving its id and acceptedAt timestamp.",
|
|
4965
|
+
parameters: Type.Object({
|
|
4966
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
4967
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
4968
|
+
decisionId: Type.String({ minLength: 1 }),
|
|
4969
|
+
title: Type.Optional(Type.String()),
|
|
4970
|
+
decision: Type.Optional(Type.String()),
|
|
4971
|
+
rationale: Type.Optional(Type.String()),
|
|
4972
|
+
implementationNotes: Type.Optional(Type.String()),
|
|
4973
|
+
}),
|
|
4974
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4975
|
+
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
4976
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4977
|
+
if (receivedFields.length === 0) {
|
|
4978
|
+
return {
|
|
4979
|
+
isError: true,
|
|
4980
|
+
content: [{ type: "text", text: "❌ ACCEPTED DECISION UPDATE FAILED [NO_MUTABLE_FIELDS_RECEIVED]\nNo accepted decision fields were supplied." }],
|
|
4981
|
+
details: noMutableFieldsReceived({
|
|
4982
|
+
entity: "acceptedDecision",
|
|
4983
|
+
ref: params.decisionId,
|
|
4984
|
+
operation: "update",
|
|
4985
|
+
mutableFields,
|
|
4986
|
+
retryCommand: "accepted_decision_update with title, decision, rationale, or implementationNotes",
|
|
4987
|
+
readBackCommand: "Read back the owning project, feature, phase, or task.",
|
|
4988
|
+
}),
|
|
4989
|
+
};
|
|
4990
|
+
}
|
|
4991
|
+
const st = await requirePlan(ctx);
|
|
4992
|
+
if (!st)
|
|
4993
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4994
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
4995
|
+
if (!target.ok)
|
|
4996
|
+
return { content: [{ type: "text", text: target.error }], details: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
4997
|
+
const updateInput = {
|
|
4998
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
4999
|
+
...(params.decision !== undefined ? { decision: params.decision } : {}),
|
|
5000
|
+
...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
|
|
5001
|
+
...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
|
|
5002
|
+
};
|
|
5003
|
+
try {
|
|
5004
|
+
const acceptedDecision = await st.updateAcceptedDecision(target.owner, params.decisionId, updateInput);
|
|
5005
|
+
await st.writeGenerated();
|
|
5006
|
+
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 } };
|
|
5007
|
+
}
|
|
5008
|
+
catch (error) {
|
|
5009
|
+
return acceptedDecisionMutationFailure(error, "updated");
|
|
5010
|
+
}
|
|
5011
|
+
},
|
|
5012
|
+
});
|
|
5013
|
+
pi.registerTool({
|
|
5014
|
+
name: "accepted_decision_delete",
|
|
5015
|
+
label: "Accepted Decision Delete",
|
|
5016
|
+
description: "Delete one accepted decision from the project, feature, phase, or task. Requires confirmed=true.",
|
|
5017
|
+
parameters: Type.Object({
|
|
5018
|
+
targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
|
|
5019
|
+
targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
|
|
5020
|
+
decisionId: Type.String({ minLength: 1 }),
|
|
5021
|
+
confirmed: Type.Boolean({ description: "Must be true to confirm deleting this accepted decision." }),
|
|
5022
|
+
}),
|
|
5023
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5024
|
+
const st = await requirePlan(ctx);
|
|
5025
|
+
if (!st)
|
|
5026
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5027
|
+
const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
|
|
5028
|
+
if (!target.ok)
|
|
5029
|
+
return { content: [{ type: "text", text: target.error }], details: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
|
|
5030
|
+
if (!params.confirmed) {
|
|
5031
|
+
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 } };
|
|
5032
|
+
}
|
|
5033
|
+
try {
|
|
5034
|
+
const acceptedDecision = await st.deleteAcceptedDecision(target.owner, params.decisionId);
|
|
5035
|
+
await st.writeGenerated();
|
|
5036
|
+
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 } };
|
|
5037
|
+
}
|
|
5038
|
+
catch (error) {
|
|
5039
|
+
return acceptedDecisionMutationFailure(error, "deleted");
|
|
5040
|
+
}
|
|
5041
|
+
},
|
|
5042
|
+
});
|
|
4519
5043
|
pi.registerTool({
|
|
4520
5044
|
name: "phase_delete",
|
|
4521
5045
|
label: "Phase Delete",
|
|
@@ -4539,18 +5063,20 @@ export default function planPiExtension(pi) {
|
|
|
4539
5063
|
catch {
|
|
4540
5064
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4541
5065
|
}
|
|
4542
|
-
await st.
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
5066
|
+
await st.runBatch(async () => {
|
|
5067
|
+
await st.deletePhase(phaseId);
|
|
5068
|
+
if (phase.featureId) {
|
|
5069
|
+
await st.updateFeatures((doc) => {
|
|
5070
|
+
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
5071
|
+
if (feature) {
|
|
5072
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
5073
|
+
feature.updatedAt = nowISO();
|
|
5074
|
+
}
|
|
5075
|
+
return doc;
|
|
5076
|
+
});
|
|
5077
|
+
}
|
|
5078
|
+
await st.writeGenerated();
|
|
5079
|
+
});
|
|
4554
5080
|
return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
|
|
4555
5081
|
},
|
|
4556
5082
|
});
|
|
@@ -4598,21 +5124,20 @@ export default function planPiExtension(pi) {
|
|
|
4598
5124
|
pi.registerTool({
|
|
4599
5125
|
name: "task_get",
|
|
4600
5126
|
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.",
|
|
5127
|
+
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
5128
|
parameters: Type.Object({
|
|
4603
5129
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4604
5130
|
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
4605
5131
|
}),
|
|
4606
5132
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4607
5133
|
const st = await requirePlan(ctx);
|
|
5134
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4608
5135
|
if (!st)
|
|
4609
5136
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4610
5137
|
const features = (await st.loadFeatures()).features;
|
|
4611
5138
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4612
5139
|
if (!found)
|
|
4613
5140
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4614
|
-
if (params.full)
|
|
4615
|
-
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
4616
5141
|
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
5142
|
if (!params.full)
|
|
4618
5143
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
@@ -4643,7 +5168,20 @@ export default function planPiExtension(pi) {
|
|
|
4643
5168
|
}
|
|
4644
5169
|
if (log)
|
|
4645
5170
|
sections.push(`Status log:\n${log}`);
|
|
4646
|
-
|
|
5171
|
+
sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
|
|
5172
|
+
const result = {
|
|
5173
|
+
content: [{ type: "text", text: sections.join("\n\n") }],
|
|
5174
|
+
details: {
|
|
5175
|
+
task: {
|
|
5176
|
+
ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5177
|
+
description: found.task.description,
|
|
5178
|
+
descriptionRef: found.task.descriptionRef ?? "",
|
|
5179
|
+
acceptedDecisions: found.task.acceptedDecisions,
|
|
5180
|
+
},
|
|
5181
|
+
},
|
|
5182
|
+
};
|
|
5183
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
5184
|
+
return result;
|
|
4647
5185
|
},
|
|
4648
5186
|
});
|
|
4649
5187
|
pi.registerTool({
|
|
@@ -4721,6 +5259,7 @@ export default function planPiExtension(pi) {
|
|
|
4721
5259
|
pauseHistory: [],
|
|
4722
5260
|
startedAt: "",
|
|
4723
5261
|
completedAt: "",
|
|
5262
|
+
activeOwnerSession: "",
|
|
4724
5263
|
createdAt: now, updatedAt: now,
|
|
4725
5264
|
};
|
|
4726
5265
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
@@ -4736,6 +5275,58 @@ export default function planPiExtension(pi) {
|
|
|
4736
5275
|
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
4737
5276
|
},
|
|
4738
5277
|
});
|
|
5278
|
+
pi.registerTool({
|
|
5279
|
+
name: "task_dependency_add",
|
|
5280
|
+
label: "Task Dependency Add",
|
|
5281
|
+
description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
|
|
5282
|
+
parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String() }),
|
|
5283
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5284
|
+
const st = await requirePlan(ctx);
|
|
5285
|
+
if (!st)
|
|
5286
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5287
|
+
const features = (await st.loadFeatures()).features;
|
|
5288
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
|
|
5289
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
|
|
5290
|
+
if (!found || !dependency)
|
|
5291
|
+
return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
|
|
5292
|
+
try {
|
|
5293
|
+
const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5294
|
+
await st.writeGenerated();
|
|
5295
|
+
return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
|
|
5296
|
+
}
|
|
5297
|
+
catch (error) {
|
|
5298
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5299
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency update failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }, isError: true };
|
|
5300
|
+
}
|
|
5301
|
+
},
|
|
5302
|
+
});
|
|
5303
|
+
pi.registerTool({
|
|
5304
|
+
name: "task_dependency_delete",
|
|
5305
|
+
label: "Task Dependency Delete",
|
|
5306
|
+
description: "Remove a task dependency atomically after explicit confirmation.",
|
|
5307
|
+
parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String(), confirmed: Type.Boolean() }),
|
|
5308
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5309
|
+
if (!params.confirmed)
|
|
5310
|
+
return { content: [{ type: "text", text: "Explicit confirmation is required." }], details: { updated: false, errorCode: "CONFIRMATION_REQUIRED" }, isError: true };
|
|
5311
|
+
const st = await requirePlan(ctx);
|
|
5312
|
+
if (!st)
|
|
5313
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5314
|
+
const features = (await st.loadFeatures()).features;
|
|
5315
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
|
|
5316
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
|
|
5317
|
+
if (!found || !dependency)
|
|
5318
|
+
return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
|
|
5319
|
+
try {
|
|
5320
|
+
const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5321
|
+
await st.writeGenerated();
|
|
5322
|
+
return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
|
|
5323
|
+
}
|
|
5324
|
+
catch (error) {
|
|
5325
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5326
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency deletion failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }, isError: true };
|
|
5327
|
+
}
|
|
5328
|
+
},
|
|
5329
|
+
});
|
|
4739
5330
|
pi.registerTool({
|
|
4740
5331
|
name: "task_update",
|
|
4741
5332
|
label: "Task Update",
|
|
@@ -4756,9 +5347,10 @@ export default function planPiExtension(pi) {
|
|
|
4756
5347
|
rationale: Type.String(),
|
|
4757
5348
|
implementationNotes: Type.String(),
|
|
4758
5349
|
acceptedAt: Type.String(),
|
|
4759
|
-
}), { description: "
|
|
5350
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4760
5351
|
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
4761
5352
|
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
5353
|
+
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
5354
|
}),
|
|
4763
5355
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4764
5356
|
const st = await requirePlan(ctx);
|
|
@@ -4787,6 +5379,13 @@ export default function planPiExtension(pi) {
|
|
|
4787
5379
|
details: {},
|
|
4788
5380
|
};
|
|
4789
5381
|
}
|
|
5382
|
+
if (params.acceptedDecisions !== undefined) {
|
|
5383
|
+
return {
|
|
5384
|
+
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." }],
|
|
5385
|
+
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)}` },
|
|
5386
|
+
isError: true,
|
|
5387
|
+
};
|
|
5388
|
+
}
|
|
4790
5389
|
if (params.status !== undefined &&
|
|
4791
5390
|
params.status !== found.task.status &&
|
|
4792
5391
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4797,7 +5396,12 @@ export default function planPiExtension(pi) {
|
|
|
4797
5396
|
};
|
|
4798
5397
|
}
|
|
4799
5398
|
}
|
|
4800
|
-
|
|
5399
|
+
if (params.subtasks !== undefined) {
|
|
5400
|
+
const existingIds = new Set(found.task.subtasks.map((item) => item.id));
|
|
5401
|
+
if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
|
|
5402
|
+
return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
|
|
5403
|
+
}
|
|
5404
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
|
|
4801
5405
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4802
5406
|
if (receivedFields.length === 0) {
|
|
4803
5407
|
return mutationNoFieldsFailure({
|
|
@@ -4837,8 +5441,6 @@ export default function planPiExtension(pi) {
|
|
|
4837
5441
|
task.notes = params.notes.trim();
|
|
4838
5442
|
if (params.decisions !== undefined)
|
|
4839
5443
|
task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
4840
|
-
if (params.acceptedDecisions !== undefined)
|
|
4841
|
-
task.acceptedDecisions = params.acceptedDecisions;
|
|
4842
5444
|
if (params.checklist !== undefined) {
|
|
4843
5445
|
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
4844
5446
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
@@ -4846,6 +5448,9 @@ export default function planPiExtension(pi) {
|
|
|
4846
5448
|
checked: false,
|
|
4847
5449
|
}));
|
|
4848
5450
|
}
|
|
5451
|
+
if (params.subtasks !== undefined) {
|
|
5452
|
+
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 }));
|
|
5453
|
+
}
|
|
4849
5454
|
if (params.status !== undefined) {
|
|
4850
5455
|
if (params.status !== task.status) {
|
|
4851
5456
|
const entry = {
|
|
@@ -4874,7 +5479,17 @@ export default function planPiExtension(pi) {
|
|
|
4874
5479
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4875
5480
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4876
5481
|
await st.writeGenerated();
|
|
4877
|
-
|
|
5482
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
5483
|
+
? await st.previewDescriptionReconciliation()
|
|
5484
|
+
: null;
|
|
5485
|
+
const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
|
|
5486
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
5487
|
+
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
5488
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
5489
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
5490
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
|
|
5491
|
+
: "";
|
|
5492
|
+
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
5493
|
},
|
|
4879
5494
|
});
|
|
4880
5495
|
pi.registerTool({
|
|
@@ -4906,21 +5521,38 @@ export default function planPiExtension(pi) {
|
|
|
4906
5521
|
pi.registerTool({
|
|
4907
5522
|
name: "task_recommend",
|
|
4908
5523
|
label: "Task Recommend",
|
|
4909
|
-
description: "Return the
|
|
5524
|
+
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
5525
|
parameters: Type.Object({}),
|
|
4911
5526
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4912
5527
|
const st = await requirePlan(ctx);
|
|
4913
5528
|
if (!st)
|
|
4914
5529
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4915
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
5530
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
5531
|
+
st.loadFeatures(),
|
|
5532
|
+
st.loadAllPhases(),
|
|
5533
|
+
st.loadProject(),
|
|
5534
|
+
st.loadResume(),
|
|
5535
|
+
st.listHandoffs(),
|
|
5536
|
+
st.listArchivedHandoffs(),
|
|
5537
|
+
]);
|
|
5538
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
5539
|
+
handoffs: handoffs.map((handoff) => ({
|
|
5540
|
+
compositeRef: handoff.compositeRef,
|
|
5541
|
+
firstLine: handoff.firstLine,
|
|
5542
|
+
resumeReady: handoff.resumeReady,
|
|
5543
|
+
})),
|
|
5544
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
5545
|
+
});
|
|
5546
|
+
const { selection } = result;
|
|
5547
|
+
if (!selection.candidate)
|
|
5548
|
+
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 } };
|
|
5549
|
+
const { candidate } = selection;
|
|
4920
5550
|
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
5551
|
+
const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
5552
|
+
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
4921
5553
|
return {
|
|
4922
|
-
content: [{ type: "text", text:
|
|
4923
|
-
details: result,
|
|
5554
|
+
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}` }],
|
|
5555
|
+
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
5556
|
};
|
|
4925
5557
|
},
|
|
4926
5558
|
});
|
|
@@ -5035,6 +5667,7 @@ export default function planPiExtension(pi) {
|
|
|
5035
5667
|
}),
|
|
5036
5668
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5037
5669
|
const st = await requirePlan(ctx);
|
|
5670
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
5038
5671
|
if (!st)
|
|
5039
5672
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5040
5673
|
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
@@ -5050,6 +5683,9 @@ export default function planPiExtension(pi) {
|
|
|
5050
5683
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
5051
5684
|
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
5052
5685
|
}
|
|
5686
|
+
if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
|
|
5687
|
+
return { content: [{ type: "text", text: `Task switch denied: source task is owned by another session.` }], details: {} };
|
|
5688
|
+
}
|
|
5053
5689
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
5054
5690
|
const linkedRequirements = [
|
|
5055
5691
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -5068,7 +5704,8 @@ export default function planPiExtension(pi) {
|
|
|
5068
5704
|
requirementIds: linkedRequirementIds,
|
|
5069
5705
|
};
|
|
5070
5706
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
5071
|
-
const
|
|
5707
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5708
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
5072
5709
|
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5073
5710
|
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
5074
5711
|
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
@@ -5086,7 +5723,7 @@ export default function planPiExtension(pi) {
|
|
|
5086
5723
|
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
|
|
5087
5724
|
}
|
|
5088
5725
|
if (!requirementsReady) {
|
|
5089
|
-
return { content: [{ type: "text", text:
|
|
5726
|
+
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
5727
|
}
|
|
5091
5728
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
5092
5729
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -5100,7 +5737,7 @@ export default function planPiExtension(pi) {
|
|
|
5100
5737
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
5101
5738
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
5102
5739
|
};
|
|
5103
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
5740
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
5104
5741
|
const record = {
|
|
5105
5742
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
5106
5743
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
@@ -5111,75 +5748,78 @@ export default function planPiExtension(pi) {
|
|
|
5111
5748
|
const sourceWasActive = source.task.status === "in-progress";
|
|
5112
5749
|
let sourceCheckpointed = false;
|
|
5113
5750
|
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
|
|
5751
|
+
await st.runBatch(async () => {
|
|
5752
|
+
try {
|
|
5753
|
+
if (sourceWasActive) {
|
|
5754
|
+
await st.pauseTask(source.phase.id, source.task.id, snapshot);
|
|
5755
|
+
}
|
|
5756
|
+
else {
|
|
5172
5757
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
5173
|
-
|
|
5758
|
+
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
5759
|
+
if (!task)
|
|
5760
|
+
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
5761
|
+
task.pauseSnapshot = snapshot;
|
|
5762
|
+
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
5763
|
+
task.updatedAt = timestamp;
|
|
5764
|
+
return phase;
|
|
5765
|
+
});
|
|
5766
|
+
}
|
|
5767
|
+
sourceCheckpointed = true;
|
|
5768
|
+
if (target.task.pauseSnapshot) {
|
|
5769
|
+
await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
|
|
5770
|
+
}
|
|
5771
|
+
else {
|
|
5772
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
5773
|
+
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
5774
|
+
if (!task)
|
|
5775
|
+
throw new Error(`Temporary task disappeared: ${params.to_task}`);
|
|
5776
|
+
const previousStatus = task.status;
|
|
5777
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
5778
|
+
task.activeOwnerSession = plannerSessionId;
|
|
5779
|
+
task.statusLog = [...task.statusLog, {
|
|
5780
|
+
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
5781
|
+
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
5782
|
+
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
5783
|
+
}];
|
|
5784
|
+
task.updatedAt = timestamp;
|
|
5785
|
+
phase.updatedAt = timestamp;
|
|
5786
|
+
return phase;
|
|
5787
|
+
});
|
|
5788
|
+
}
|
|
5789
|
+
targetStarted = true;
|
|
5790
|
+
await st.addWorkDeviation(record);
|
|
5791
|
+
if (target.task.pauseSnapshot) {
|
|
5792
|
+
const resumedDeviation = project.workDeviations
|
|
5793
|
+
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
5794
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
5795
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
5796
|
+
if (resumedDeviation)
|
|
5797
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
5798
|
+
}
|
|
5799
|
+
}
|
|
5800
|
+
catch (error) {
|
|
5801
|
+
if (targetStarted) {
|
|
5802
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
5803
|
+
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
5174
5804
|
return phase;
|
|
5175
5805
|
}).catch(() => { });
|
|
5806
|
+
}
|
|
5807
|
+
if (sourceCheckpointed) {
|
|
5808
|
+
if (sourceWasActive)
|
|
5809
|
+
await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
|
|
5810
|
+
else
|
|
5811
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
5812
|
+
phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
|
|
5813
|
+
return phase;
|
|
5814
|
+
}).catch(() => { });
|
|
5815
|
+
}
|
|
5816
|
+
throw error;
|
|
5176
5817
|
}
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
await st.writeGenerated();
|
|
5818
|
+
await st.syncTaskStatusRollup(source.phase.id);
|
|
5819
|
+
if (target.phase.id !== source.phase.id)
|
|
5820
|
+
await st.syncTaskStatusRollup(target.phase.id);
|
|
5821
|
+
await st.writeGenerated();
|
|
5822
|
+
});
|
|
5183
5823
|
invalidateReads(plannerSessionId);
|
|
5184
5824
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
5185
5825
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
@@ -5194,6 +5834,31 @@ export default function planPiExtension(pi) {
|
|
|
5194
5834
|
};
|
|
5195
5835
|
},
|
|
5196
5836
|
});
|
|
5837
|
+
pi.registerTool({
|
|
5838
|
+
name: "task_reopen",
|
|
5839
|
+
label: "Task Reopen",
|
|
5840
|
+
description: "Atomically reopen a completed task. Requires confirmed=true, retains completion evidence, and never pauses another task.",
|
|
5841
|
+
parameters: Type.Object({ taskId: Type.String({ description: "Task reference" }), confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }) }),
|
|
5842
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5843
|
+
const st = await requirePlan(ctx);
|
|
5844
|
+
if (!st)
|
|
5845
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: { reopened: false, errorCode: "PLAN_NOT_FOUND" }, isError: true };
|
|
5846
|
+
const features = (await st.loadFeatures()).features;
|
|
5847
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
5848
|
+
if (!found)
|
|
5849
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { reopened: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
|
|
5850
|
+
try {
|
|
5851
|
+
const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed: params.confirmed, ownerSessionId: `pi:${ctx.sessionManager.getSessionId()}` });
|
|
5852
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
5853
|
+
await st.writeGenerated();
|
|
5854
|
+
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 } };
|
|
5855
|
+
}
|
|
5856
|
+
catch (error) {
|
|
5857
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
5858
|
+
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 };
|
|
5859
|
+
}
|
|
5860
|
+
},
|
|
5861
|
+
});
|
|
5197
5862
|
pi.registerTool({
|
|
5198
5863
|
name: "task_start",
|
|
5199
5864
|
label: "Task Start",
|
|
@@ -5203,153 +5868,158 @@ export default function planPiExtension(pi) {
|
|
|
5203
5868
|
}),
|
|
5204
5869
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
5205
5870
|
const st = await requirePlan(ctx);
|
|
5871
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
5206
5872
|
if (!st)
|
|
5207
5873
|
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({
|
|
5874
|
+
return st.runBatch(async () => {
|
|
5875
|
+
const features = (await st.loadFeatures()).features;
|
|
5876
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
5877
|
+
if (!found)
|
|
5878
|
+
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
5879
|
+
const task = found.task;
|
|
5880
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
5881
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
5882
|
+
const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
5883
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
5884
|
+
if (task.status === "done")
|
|
5885
|
+
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 }));
|
|
5886
|
+
const project = await st.loadProject();
|
|
5887
|
+
const linkedRequirements = [
|
|
5888
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
5889
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
5890
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
5891
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
5892
|
+
const contextInput = {
|
|
5251
5893
|
sessionId: plannerSessionId,
|
|
5252
|
-
phaseId: found.phase.id,
|
|
5253
5894
|
taskId: task.id,
|
|
5895
|
+
phaseId: found.phase.id,
|
|
5254
5896
|
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5897
|
+
task,
|
|
5898
|
+
phase: found.phase,
|
|
5899
|
+
...(feature ? { feature } : {}),
|
|
5900
|
+
requirements: linkedRequirements,
|
|
5255
5901
|
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
|
-
|
|
5902
|
+
};
|
|
5903
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
5904
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5905
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
5906
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5907
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
5908
|
+
if (!projectGuidelinesReady) {
|
|
5909
|
+
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 });
|
|
5910
|
+
}
|
|
5911
|
+
if (!contextEligibility.eligible) {
|
|
5912
|
+
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 });
|
|
5913
|
+
}
|
|
5914
|
+
if (!requirementsReady) {
|
|
5915
|
+
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 });
|
|
5916
|
+
}
|
|
5917
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
5918
|
+
await st.recordContextRead({
|
|
5919
|
+
sessionId: plannerSessionId,
|
|
5920
|
+
phaseId: found.phase.id,
|
|
5921
|
+
taskId: task.id,
|
|
5922
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5923
|
+
requirementIds: linkedRequirementIds,
|
|
5924
|
+
});
|
|
5925
|
+
}
|
|
5926
|
+
if (task.status === "in-progress") {
|
|
5927
|
+
const outcome = taskStartSucceeded(task.id, true);
|
|
5928
|
+
return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
|
|
5929
|
+
}
|
|
5930
|
+
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
5931
|
+
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
5932
|
+
if (!eligibility.eligible)
|
|
5933
|
+
return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
|
|
5934
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
5935
|
+
if (selection.kind === "conflict") {
|
|
5936
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
5937
|
+
}
|
|
5938
|
+
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
5939
|
+
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
5940
|
+
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 });
|
|
5941
|
+
}
|
|
5942
|
+
let resumeProposal;
|
|
5943
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
5944
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
5945
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
5946
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
5947
|
+
// machine-readable for the host/agent.
|
|
5948
|
+
const resumeTask = selection.candidate;
|
|
5949
|
+
const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
|
|
5950
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
5951
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
5952
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
5953
|
+
?? null;
|
|
5954
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
5955
|
+
ref: resumeRef,
|
|
5956
|
+
title: resumeTask.task.title,
|
|
5957
|
+
taskId: resumeTask.task.id,
|
|
5958
|
+
phaseId: resumeTask.phase.id,
|
|
5959
|
+
snapshot: resumeSnapshot
|
|
5960
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
5961
|
+
: null,
|
|
5962
|
+
});
|
|
5963
|
+
}
|
|
5964
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
5965
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
5966
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
5967
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
5968
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
5969
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
5970
|
+
]);
|
|
5971
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
|
|
5972
|
+
const advisory = selection.candidate && selection.candidate.task.id !== task.id
|
|
5973
|
+
? selection.kind === "resume"
|
|
5974
|
+
? `\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.`}`
|
|
5975
|
+
: `\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.`
|
|
5976
|
+
: "";
|
|
5977
|
+
const now = nowISO();
|
|
5978
|
+
let startedTask;
|
|
5979
|
+
if (task.pauseSnapshot) {
|
|
5980
|
+
startedTask = await st.resumeTask(found.phase.id, found.task.id, now, plannerSessionId);
|
|
5981
|
+
}
|
|
5982
|
+
else {
|
|
5983
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
5984
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
5985
|
+
if (!t)
|
|
5986
|
+
return phase;
|
|
5987
|
+
const previousStatus = t.status;
|
|
5988
|
+
applyTaskLifecycleDates(t, "in-progress", now);
|
|
5989
|
+
t.activeOwnerSession = plannerSessionId;
|
|
5990
|
+
t.statusLog = [...t.statusLog, {
|
|
5991
|
+
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
5992
|
+
date: now, fromStatus: previousStatus, toStatus: "in-progress",
|
|
5993
|
+
title: `${previousStatus} → in-progress`, description: "",
|
|
5994
|
+
}];
|
|
5995
|
+
t.updatedAt = now;
|
|
5996
|
+
phase.updatedAt = now;
|
|
5997
|
+
startedTask = t;
|
|
5318
5998
|
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
|
-
};
|
|
5999
|
+
});
|
|
6000
|
+
}
|
|
6001
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
6002
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
|
|
6003
|
+
if (approvedDeviation)
|
|
6004
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", now);
|
|
6005
|
+
const resumedDeviation = project.workDeviations
|
|
6006
|
+
.filter((deviation) => deviation.resumeTaskId === task.id
|
|
6007
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
6008
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
6009
|
+
if (resumedDeviation)
|
|
6010
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
6011
|
+
await st.writeGenerated();
|
|
6012
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
6013
|
+
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
6014
|
+
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 }));
|
|
6015
|
+
}
|
|
6016
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
6017
|
+
return {
|
|
6018
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
6019
|
+
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
6020
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
6021
|
+
};
|
|
6022
|
+
});
|
|
5353
6023
|
},
|
|
5354
6024
|
});
|
|
5355
6025
|
pi.registerTool({
|
|
@@ -5772,12 +6442,15 @@ export default function planPiExtension(pi) {
|
|
|
5772
6442
|
const openQuestions = plan.phases
|
|
5773
6443
|
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
5774
6444
|
.slice(0, 8);
|
|
5775
|
-
const
|
|
5776
|
-
|
|
5777
|
-
...plan.features.features.
|
|
5778
|
-
...plan.phases.
|
|
5779
|
-
...plan.phases.flatMap((phase) => phase.tasks.
|
|
5780
|
-
|
|
6445
|
+
const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
|
|
6446
|
+
{ scope: "project", decisions: project.acceptedDecisions },
|
|
6447
|
+
...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
|
|
6448
|
+
...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
|
|
6449
|
+
...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
|
|
6450
|
+
scope: `task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
|
|
6451
|
+
decisions: task.acceptedDecisions,
|
|
6452
|
+
}))),
|
|
6453
|
+
]);
|
|
5781
6454
|
const webServer = server;
|
|
5782
6455
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
5783
6456
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -5850,8 +6523,7 @@ export default function planPiExtension(pi) {
|
|
|
5850
6523
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
5851
6524
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
5852
6525
|
"",
|
|
5853
|
-
|
|
5854
|
-
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
6526
|
+
acceptedDecisionContext.content,
|
|
5855
6527
|
openQuestions.length ? "" : "",
|
|
5856
6528
|
openQuestions.length ? "Open questions:" : "",
|
|
5857
6529
|
...openQuestions.map((question) => `- ${question}`),
|