@agent-plan/pi-adapter 0.2.25 → 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 +1934 -662
- 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-CYtuauJC.css +0 -1
- package/web-ui-dist/assets/index-DubXBudL.js +0 -318
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, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion,
|
|
14
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
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
|
+
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";
|
|
@@ -85,16 +86,74 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
|
|
|
85
86
|
},
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
|
-
function
|
|
89
|
-
const
|
|
89
|
+
function mutationNoFieldsFailure(input) {
|
|
90
|
+
const outcome = noMutableFieldsReceived(input);
|
|
91
|
+
return {
|
|
92
|
+
isError: true,
|
|
93
|
+
content: [{ type: "text", text: outcome.message }],
|
|
94
|
+
details: outcome,
|
|
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
|
+
}
|
|
106
|
+
function legacyContextMigrationSummary(preview) {
|
|
107
|
+
return [
|
|
108
|
+
`Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
|
|
109
|
+
`Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
|
|
110
|
+
`Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
|
|
111
|
+
`Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
|
|
112
|
+
`Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
function handoffContractFailure(error) {
|
|
116
|
+
return {
|
|
117
|
+
isError: true,
|
|
118
|
+
content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
|
|
119
|
+
details: { errorCode: error.code, ...error.details },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function boundedHandoffForTransport(content) {
|
|
123
|
+
if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
|
|
124
|
+
return { content, fullLength: content.length, truncated: false };
|
|
125
|
+
const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
|
|
126
|
+
return {
|
|
127
|
+
content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
|
|
128
|
+
fullLength: content.length,
|
|
129
|
+
truncated: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function boundedPage(items, page, pageSize) {
|
|
133
|
+
const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
|
|
134
|
+
const totalPages = Math.max(1, Math.ceil(items.length / size));
|
|
135
|
+
const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
|
|
136
|
+
return {
|
|
137
|
+
items: items.slice((currentPage - 1) * size, currentPage * size),
|
|
138
|
+
page: currentPage,
|
|
139
|
+
pageSize: size,
|
|
140
|
+
total: items.length,
|
|
141
|
+
totalPages,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
|
|
145
|
+
const actions = projectGuidelinesReadRequired ? ["project_guidelines_show"] : [];
|
|
146
|
+
actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
|
|
90
147
|
if (read.kind === "task")
|
|
91
148
|
return [`task_get ${refs.task} with full=true`];
|
|
92
149
|
if (read.kind === "phase")
|
|
93
150
|
return [`phase_get ${refs.phase} with full=true`];
|
|
94
|
-
|
|
95
|
-
|
|
151
|
+
if (read.kind === "feature")
|
|
152
|
+
return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
|
|
153
|
+
return [];
|
|
154
|
+
}));
|
|
96
155
|
if (requirementReadRequired)
|
|
97
|
-
actions.push(
|
|
156
|
+
actions.push(`requirement_list with phaseRef=${refs.phase}`);
|
|
98
157
|
actions.push(retryAction);
|
|
99
158
|
return actions;
|
|
100
159
|
}
|
|
@@ -172,6 +231,14 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
172
231
|
{ value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
|
|
173
232
|
{ value: "project discuss", label: "project discuss", description: "Run project discovery" },
|
|
174
233
|
{ value: "project language", label: "project language", description: "Set persistent language preferences" },
|
|
234
|
+
{ value: "project guidelines", label: "project guidelines", description: "Edit the canonical Project Guidelines section" },
|
|
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" },
|
|
175
242
|
{ value: "feature list", label: "feature list", description: "List features" },
|
|
176
243
|
{ value: "feature add", label: "feature add", description: "Create a feature" },
|
|
177
244
|
{ value: "feature show", label: "feature show", description: "Show a feature" },
|
|
@@ -280,7 +347,7 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
|
280
347
|
project.contentLanguage = contentLanguage;
|
|
281
348
|
project.chatLanguage = chatLanguage;
|
|
282
349
|
if (persist)
|
|
283
|
-
await st.
|
|
350
|
+
await st.updateProject((current) => ({ ...current, contentLanguage, chatLanguage }));
|
|
284
351
|
}
|
|
285
352
|
return project;
|
|
286
353
|
}
|
|
@@ -289,7 +356,11 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
|
289
356
|
project.contentLanguage = contentLanguage || fallback;
|
|
290
357
|
project.chatLanguage = chatLanguage || fallback;
|
|
291
358
|
if (persist)
|
|
292
|
-
await st.
|
|
359
|
+
await st.updateProject((current) => ({
|
|
360
|
+
...current,
|
|
361
|
+
contentLanguage: project.contentLanguage,
|
|
362
|
+
chatLanguage: project.chatLanguage,
|
|
363
|
+
}));
|
|
293
364
|
return project;
|
|
294
365
|
}
|
|
295
366
|
return project;
|
|
@@ -624,6 +695,33 @@ function statusIcon(status) {
|
|
|
624
695
|
function pad(n) {
|
|
625
696
|
return String(n).padStart(3, "0");
|
|
626
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
|
+
}
|
|
627
725
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
628
726
|
const previousStatus = task.status;
|
|
629
727
|
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
@@ -656,8 +754,7 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
656
754
|
if (!(await isPortAvailable(explicitPort)) && project.webPort !== explicitPort) {
|
|
657
755
|
throw new Error(`Port ${explicitPort} is already in use`);
|
|
658
756
|
}
|
|
659
|
-
|
|
660
|
-
await st.saveProject(project);
|
|
757
|
+
await st.updateProject((current) => ({ ...current, webPort: explicitPort }));
|
|
661
758
|
return explicitPort;
|
|
662
759
|
}
|
|
663
760
|
// Reuse the persisted port if it's still free (stable port across restarts,
|
|
@@ -674,8 +771,7 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
674
771
|
continue;
|
|
675
772
|
if (await isPortAvailable(port)) {
|
|
676
773
|
if (project.webPort <= 0) {
|
|
677
|
-
|
|
678
|
-
await st.saveProject(project);
|
|
774
|
+
await st.updateProject((current) => ({ ...current, webPort: port }));
|
|
679
775
|
}
|
|
680
776
|
return port;
|
|
681
777
|
}
|
|
@@ -807,8 +903,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
807
903
|
// existing canonical). Never persist a transient fallback port — that
|
|
808
904
|
// hijacks the primary session's port when two instances share a .planner.
|
|
809
905
|
if (project.webPort <= 0 || project.webPort === realPort) {
|
|
810
|
-
|
|
811
|
-
await st.saveProject(project);
|
|
906
|
+
await st.updateProject((current) => ({ ...current, webPort: realPort }));
|
|
812
907
|
}
|
|
813
908
|
}
|
|
814
909
|
}
|
|
@@ -1139,6 +1234,13 @@ export default function planPiExtension(pi) {
|
|
|
1139
1234
|
"cleanup-orphans",
|
|
1140
1235
|
"project discuss",
|
|
1141
1236
|
"project language",
|
|
1237
|
+
"project guidelines",
|
|
1238
|
+
"idea list",
|
|
1239
|
+
"idea add",
|
|
1240
|
+
"idea show",
|
|
1241
|
+
"idea update",
|
|
1242
|
+
"idea delete",
|
|
1243
|
+
"idea promote",
|
|
1142
1244
|
"feature list",
|
|
1143
1245
|
"feature add",
|
|
1144
1246
|
"feature show",
|
|
@@ -1170,7 +1272,7 @@ export default function planPiExtension(pi) {
|
|
|
1170
1272
|
"load",
|
|
1171
1273
|
"stop",
|
|
1172
1274
|
];
|
|
1173
|
-
const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1275
|
+
const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, idea, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1174
1276
|
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
|
|
1175
1277
|
"Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
|
|
1176
1278
|
if (!a) {
|
|
@@ -1183,11 +1285,16 @@ export default function planPiExtension(pi) {
|
|
|
1183
1285
|
return;
|
|
1184
1286
|
}
|
|
1185
1287
|
if (a === "version") {
|
|
1288
|
+
const packages = runtimePackagesDiagnostic([PI_ADAPTER_PACKAGE, CORE_PACKAGE, SERVER_PACKAGE]);
|
|
1289
|
+
const capabilities = runtimeCapabilities();
|
|
1186
1290
|
ctx.ui.notify([
|
|
1187
|
-
"Agent Plan runtime
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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(", ")}`,
|
|
1191
1298
|
].join("\n"), "info");
|
|
1192
1299
|
return;
|
|
1193
1300
|
}
|
|
@@ -1254,12 +1361,18 @@ export default function planPiExtension(pi) {
|
|
|
1254
1361
|
// ── show ──
|
|
1255
1362
|
if (a === "show") {
|
|
1256
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)`));
|
|
1257
1367
|
ctx.ui.notify([
|
|
1258
1368
|
`📋 **${plan.project.name}**`,
|
|
1259
1369
|
` Description: ${plan.project.description || "*not set*"}`,
|
|
1260
1370
|
` Goal: ${plan.project.goal || "*not set*"}`,
|
|
1261
1371
|
` Phases: ${plan.phases.length}`,
|
|
1262
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}`)]),
|
|
1263
1376
|
` Updated: ${plan.manifest.updatedAt}`,
|
|
1264
1377
|
].join("\n"), "info");
|
|
1265
1378
|
return;
|
|
@@ -1472,7 +1585,7 @@ export default function planPiExtension(pi) {
|
|
|
1472
1585
|
// ═══════════════════════════════════════════════════════════════
|
|
1473
1586
|
if (a === "project") {
|
|
1474
1587
|
if (!b) {
|
|
1475
|
-
ctx.ui.notify("project actions: discuss | language", "info");
|
|
1588
|
+
ctx.ui.notify("project actions: discuss | language | guidelines | migrate-context", "info");
|
|
1476
1589
|
return;
|
|
1477
1590
|
}
|
|
1478
1591
|
if (b === "discuss") {
|
|
@@ -1503,7 +1616,17 @@ export default function planPiExtension(pi) {
|
|
|
1503
1616
|
}))
|
|
1504
1617
|
: project.acceptedDecisions,
|
|
1505
1618
|
};
|
|
1506
|
-
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
|
+
}));
|
|
1507
1630
|
await st.saveCodebaseProfile(profile);
|
|
1508
1631
|
await st.writeGenerated();
|
|
1509
1632
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
@@ -1529,12 +1652,124 @@ export default function planPiExtension(pi) {
|
|
|
1529
1652
|
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
|
|
1530
1653
|
project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
|
|
1531
1654
|
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
|
|
1532
|
-
await st.
|
|
1655
|
+
await st.updateProject((current) => ({
|
|
1656
|
+
...current,
|
|
1657
|
+
contentLanguage: project.contentLanguage,
|
|
1658
|
+
chatLanguage: project.chatLanguage,
|
|
1659
|
+
}));
|
|
1533
1660
|
await st.writeGenerated();
|
|
1534
1661
|
ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
|
|
1535
1662
|
return;
|
|
1536
1663
|
}
|
|
1537
|
-
|
|
1664
|
+
if (b === "guidelines") {
|
|
1665
|
+
const project = await st.loadProject();
|
|
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 || "");
|
|
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 });
|
|
1673
|
+
await st.writeGenerated();
|
|
1674
|
+
ctx.ui.notify(project.projectGuidelines.content ? "Project Guidelines updated." : "Project Guidelines cleared.", "info");
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
if (b === "migrate-context") {
|
|
1678
|
+
const preview = await st.previewLegacyProjectContextMigration();
|
|
1679
|
+
const summary = legacyContextMigrationSummary(preview);
|
|
1680
|
+
if (!preview.hasLegacyContext) {
|
|
1681
|
+
ctx.ui.notify(`Project context migration preview:\n${summary}\nNo migration is needed.`, "info");
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const choice = await ctx.ui.select("Apply the previewed migration? This clears only migrated legacy fields.", ["apply", "cancel"]);
|
|
1685
|
+
if (choice !== "apply") {
|
|
1686
|
+
ctx.ui.notify(`Project context migration preview (no changes applied):\n${summary}`, "info");
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const result = await st.migrateLegacyProjectContext();
|
|
1690
|
+
await st.writeGenerated();
|
|
1691
|
+
ctx.ui.notify(`${result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration."}\n${legacyContextMigrationSummary(result.preview)}`, "info");
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language, guidelines, migrate-context`, "warning");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1698
|
+
// idea <sub>
|
|
1699
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1700
|
+
if (a === "idea") {
|
|
1701
|
+
if (!b) {
|
|
1702
|
+
ctx.ui.notify("idea actions: list | add [title] | show [ref] | update [ref] | delete [ref] | promote [ref]", "info");
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const ideas = (await st.loadIdeas()).ideas;
|
|
1706
|
+
if (b === "list") {
|
|
1707
|
+
await paginatedNotify(ctx, {
|
|
1708
|
+
title: "ideas",
|
|
1709
|
+
lines: ideas.map((idea) => `${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`),
|
|
1710
|
+
pageSize: 10,
|
|
1711
|
+
});
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (b === "add") {
|
|
1715
|
+
const title = subArgs.trim() || await ctx.ui.input("Idea title");
|
|
1716
|
+
if (!title?.trim()) {
|
|
1717
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const description = await ctx.ui.editor("Idea description", "");
|
|
1721
|
+
const idea = await st.createIdea({ title: title.trim(), ...(description?.trim() ? { description: description.trim() } : {}) });
|
|
1722
|
+
await st.writeGenerated();
|
|
1723
|
+
ctx.ui.notify(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, "info");
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const ref = subArgs.trim() || await ctx.ui.input("Idea ref (I00x, short ID, or title)");
|
|
1727
|
+
const idea = ref ? findIdeaByRef(ideas, ref) : undefined;
|
|
1728
|
+
if (!idea) {
|
|
1729
|
+
ctx.ui.notify(`Idea not found: ${ref || "(empty)"}`, "error");
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (b === "show") {
|
|
1733
|
+
ctx.ui.notify(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}${idea.promotion ? `\n\nPromoted to: ${idea.promotion.targetRef}` : ""}`, "info");
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
if (b === "update") {
|
|
1737
|
+
const title = await ctx.ui.input(`Title [${idea.title}]`);
|
|
1738
|
+
const description = await ctx.ui.editor("Description", idea.description);
|
|
1739
|
+
const updated = await st.updateIdea(idea.id, { ...(title?.trim() ? { title: title.trim() } : {}), ...(description !== undefined ? { description: description.trim() } : {}) });
|
|
1740
|
+
await st.writeGenerated();
|
|
1741
|
+
ctx.ui.notify(`Idea updated: ${formatIdeaRef(updated.number)} — ${updated.title}`, "info");
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (b === "delete") {
|
|
1745
|
+
const answer = await ctx.ui.select(`Delete ${formatIdeaRef(idea.number)} — ${idea.title}?`, ["cancel", "delete"]);
|
|
1746
|
+
if (answer !== "delete") {
|
|
1747
|
+
ctx.ui.notify("Cancelled — no changes saved.", "info");
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
await st.deleteIdea(idea.id);
|
|
1751
|
+
await st.writeGenerated();
|
|
1752
|
+
ctx.ui.notify(`Idea deleted: ${formatIdeaRef(idea.number)}.`, "info");
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (b === "promote") {
|
|
1756
|
+
if (idea.promotion) {
|
|
1757
|
+
ctx.ui.notify(`Already promoted to ${idea.promotion.targetRef}.`, "warning");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
const targetType = await ctx.ui.select("Promotion target", ["feature", "phase", "task"]);
|
|
1761
|
+
if (!targetType)
|
|
1762
|
+
return;
|
|
1763
|
+
const skill = await st.ideaDiscussionSkill();
|
|
1764
|
+
if (capturedPi) {
|
|
1765
|
+
await capturedPi.sendUserMessage(`Discuss promotion of ${formatIdeaRef(idea.number)} — ${idea.title} to a ${targetType}.\n\n${skill}\n\nDo not create or persist a target until the discussion is complete and I explicitly confirm the exact destination. For a phase, recommend and confirm its feature. For a task, recommend and confirm its feature and phase. After creating the confirmed target with the normal planner tool, record the promotion with idea_promotion_finalize.`);
|
|
1766
|
+
}
|
|
1767
|
+
else {
|
|
1768
|
+
ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
|
|
1769
|
+
}
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
ctx.ui.notify(`Unknown idea action "${b}". Try: list, add, show, update, delete, promote`, "warning");
|
|
1538
1773
|
return;
|
|
1539
1774
|
}
|
|
1540
1775
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1762,9 +1997,7 @@ export default function planPiExtension(pi) {
|
|
|
1762
1997
|
}
|
|
1763
1998
|
else {
|
|
1764
1999
|
for (const phase of phases) {
|
|
1765
|
-
phase.featureId
|
|
1766
|
-
phase.updatedAt = nowISO();
|
|
1767
|
-
await st.savePhase(phase);
|
|
2000
|
+
await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
|
|
1768
2001
|
affectedPhases += 1;
|
|
1769
2002
|
}
|
|
1770
2003
|
}
|
|
@@ -1804,7 +2037,7 @@ export default function planPiExtension(pi) {
|
|
|
1804
2037
|
// and only then abandoning the follow-up question.
|
|
1805
2038
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1806
2039
|
let phase;
|
|
1807
|
-
await withFeatureLock(feature.id, async () => {
|
|
2040
|
+
await st.runBatch(() => withFeatureLock(feature.id, async () => {
|
|
1808
2041
|
const phases = await st.loadAllPhases();
|
|
1809
2042
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1810
2043
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
@@ -1816,7 +2049,7 @@ export default function planPiExtension(pi) {
|
|
|
1816
2049
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
1817
2050
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1818
2051
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1819
|
-
handoff: "", handoffUpdatedAt: "",
|
|
2052
|
+
handoff: "", handoffUpdatedAt: "", handoffAudit: null,
|
|
1820
2053
|
handoffReadAt: "",
|
|
1821
2054
|
handoffHistory: [],
|
|
1822
2055
|
statusLog: [],
|
|
@@ -1832,7 +2065,7 @@ export default function planPiExtension(pi) {
|
|
|
1832
2065
|
return doc;
|
|
1833
2066
|
});
|
|
1834
2067
|
await st.writeGenerated();
|
|
1835
|
-
});
|
|
2068
|
+
}));
|
|
1836
2069
|
if (!phase)
|
|
1837
2070
|
return;
|
|
1838
2071
|
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
@@ -1880,8 +2113,12 @@ export default function planPiExtension(pi) {
|
|
|
1880
2113
|
ctx.ui.notify(recapLines.join("\n"), "info");
|
|
1881
2114
|
const changeNote = await ctx.ui.input("Anything changed? (free text, or leave empty to keep current plan)");
|
|
1882
2115
|
if (changeNote?.trim()) {
|
|
1883
|
-
|
|
1884
|
-
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
|
+
}));
|
|
1885
2122
|
if (capturedPi) {
|
|
1886
2123
|
await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
|
|
1887
2124
|
"Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
|
|
@@ -1903,25 +2140,22 @@ export default function planPiExtension(pi) {
|
|
|
1903
2140
|
if (ans?.trim())
|
|
1904
2141
|
answers[q.key] = ans.trim();
|
|
1905
2142
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
phase
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
phase.status = "planned";
|
|
1921
|
-
phase.updatedAt = nowISO();
|
|
1922
|
-
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
|
+
}));
|
|
1923
2157
|
await st.writeGenerated();
|
|
1924
|
-
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");
|
|
1925
2159
|
return;
|
|
1926
2160
|
}
|
|
1927
2161
|
if (b === "show") {
|
|
@@ -1993,33 +2227,25 @@ export default function planPiExtension(pi) {
|
|
|
1993
2227
|
const deps = await ctx.ui.input(`Dependencies (comma-separated) [${phase.dependencies.join(", ") || ""}]`);
|
|
1994
2228
|
const risks = await ctx.ui.input(`Risks (comma-separated) [${phase.risks.join(", ") || ""}]`);
|
|
1995
2229
|
const completion = await ctx.ui.input(`Completion criteria (comma-separated) [${phase.completionCriteria.join(", ") || ""}]`);
|
|
1996
|
-
// Update only changed fields
|
|
1997
|
-
if (title?.trim())
|
|
1998
|
-
phase.title = title.trim();
|
|
1999
2230
|
if (statusInput?.trim()) {
|
|
2000
2231
|
const valid = ["draft", "discovery", "planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
2001
2232
|
if (!valid.includes(statusInput.trim())) {
|
|
2002
2233
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2003
2234
|
return;
|
|
2004
2235
|
}
|
|
2005
|
-
phase.status = statusInput.trim();
|
|
2006
2236
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
if (completion?.trim())
|
|
2020
|
-
phase.completionCriteria = completion.split(",").map((g) => g.trim()).filter(Boolean);
|
|
2021
|
-
phase.updatedAt = nowISO();
|
|
2022
|
-
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
|
+
}));
|
|
2023
2249
|
await st.writeGenerated();
|
|
2024
2250
|
ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
2025
2251
|
return;
|
|
@@ -2068,12 +2294,15 @@ export default function planPiExtension(pi) {
|
|
|
2068
2294
|
dependsOn: [],
|
|
2069
2295
|
pauseSnapshot: null, pauseHistory: [],
|
|
2070
2296
|
startedAt: "", completedAt: "",
|
|
2297
|
+
activeOwnerSession: "",
|
|
2071
2298
|
createdAt: now, updatedAt: now,
|
|
2072
2299
|
};
|
|
2073
|
-
phase.
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2300
|
+
await st.updatePhase(phase.id, (current) => ({
|
|
2301
|
+
...current,
|
|
2302
|
+
tasks: [...current.tasks, task],
|
|
2303
|
+
taskIds: [...current.taskIds, taskId],
|
|
2304
|
+
updatedAt: nowISO(),
|
|
2305
|
+
}));
|
|
2077
2306
|
await st.writeGenerated();
|
|
2078
2307
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
2079
2308
|
if (isYes(startDiscuss)) {
|
|
@@ -2104,18 +2333,20 @@ export default function planPiExtension(pi) {
|
|
|
2104
2333
|
const { phase, task } = resolved;
|
|
2105
2334
|
const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
|
|
2106
2335
|
const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
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,
|
|
2111
2339
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
2112
2340
|
title: itemTitle,
|
|
2113
2341
|
checked: false,
|
|
2114
|
-
}))
|
|
2115
|
-
|
|
2116
|
-
task.
|
|
2117
|
-
|
|
2118
|
-
|
|
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
|
+
}));
|
|
2119
2350
|
await st.writeGenerated();
|
|
2120
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");
|
|
2121
2352
|
return;
|
|
@@ -2181,10 +2412,12 @@ export default function planPiExtension(pi) {
|
|
|
2181
2412
|
ctx.ui.notify("Aborted", "warning");
|
|
2182
2413
|
return;
|
|
2183
2414
|
}
|
|
2184
|
-
phase.
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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
|
+
}));
|
|
2188
2421
|
await st.writeGenerated();
|
|
2189
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");
|
|
2190
2423
|
return;
|
|
@@ -2213,125 +2446,137 @@ export default function planPiExtension(pi) {
|
|
|
2213
2446
|
const title = await ctx.ui.input(`Title [${task.title}]`);
|
|
2214
2447
|
const statusInput = await ctx.ui.input(`Status [${task.status}] (planned|in-progress|done|blocked|canceled)`);
|
|
2215
2448
|
const desc = await ctx.ui.input(`Description [leave empty to keep]`);
|
|
2216
|
-
// Update only changed fields
|
|
2217
|
-
if (title?.trim())
|
|
2218
|
-
task.title = title.trim();
|
|
2219
2449
|
const now = nowISO();
|
|
2220
|
-
|
|
2221
|
-
|
|
2450
|
+
const normalizedStatus = statusInput?.trim();
|
|
2451
|
+
if (normalizedStatus) {
|
|
2222
2452
|
const valid = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
2223
2453
|
if (!valid.includes(normalizedStatus)) {
|
|
2224
2454
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2225
2455
|
return;
|
|
2226
2456
|
}
|
|
2227
2457
|
if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
|
|
2228
|
-
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");
|
|
2229
2459
|
return;
|
|
2230
2460
|
}
|
|
2231
2461
|
if (normalizedStatus === "done" && normalizedStatus !== task.status) {
|
|
2232
2462
|
ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
|
|
2233
2463
|
return;
|
|
2234
2464
|
}
|
|
2235
|
-
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2236
2465
|
}
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
+
});
|
|
2242
2478
|
await st.syncTaskStatusRollup(phase.id);
|
|
2243
2479
|
await st.writeGenerated();
|
|
2244
|
-
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");
|
|
2245
2481
|
return;
|
|
2246
2482
|
}
|
|
2247
2483
|
if (b === "start") {
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
if (!task) {
|
|
2256
|
-
phase = await pickPhase();
|
|
2257
|
-
if (!phase) {
|
|
2258
|
-
ctx.ui.notify("No phases available", "warning");
|
|
2259
|
-
return;
|
|
2260
|
-
}
|
|
2261
|
-
if (phase.tasks.length === 0) {
|
|
2262
|
-
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2263
|
-
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;
|
|
2264
2491
|
}
|
|
2265
|
-
task = await pickTask(phase);
|
|
2266
2492
|
if (!task) {
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
+
}
|
|
2269
2507
|
}
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
const
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
+
}
|
|
2299
2553
|
}
|
|
2300
|
-
if (
|
|
2301
|
-
ctx.ui.notify("
|
|
2554
|
+
if (task.status === "in-progress") {
|
|
2555
|
+
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2302
2556
|
return;
|
|
2303
2557
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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");
|
|
2310
2578
|
return;
|
|
2311
|
-
}
|
|
2312
|
-
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2313
|
-
// It is archived only when the phase completes (auto) or the user
|
|
2314
|
-
// explicitly clears it.
|
|
2315
|
-
const now = nowISO();
|
|
2316
|
-
// Record status change in the incremental statusLog.
|
|
2317
|
-
const prevStatus = task.status;
|
|
2318
|
-
const entry = {
|
|
2319
|
-
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
|
|
2320
|
-
date: now,
|
|
2321
|
-
fromStatus: task.status,
|
|
2322
|
-
toStatus: "in-progress",
|
|
2323
|
-
title: task.status === "done" ? "Reopened" : `→ in-progress`,
|
|
2324
|
-
description: task.status === "done" ? "Task reopened from done status." : "",
|
|
2325
|
-
};
|
|
2326
|
-
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2327
|
-
applyTaskLifecycleDates(task, "in-progress", now);
|
|
2328
|
-
task.updatedAt = now;
|
|
2329
|
-
phase.updatedAt = now;
|
|
2330
|
-
await st.savePhase(phase);
|
|
2331
|
-
await st.syncTaskStatusRollup(phase.id);
|
|
2332
|
-
await st.writeGenerated();
|
|
2333
|
-
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");
|
|
2334
|
-
return;
|
|
2579
|
+
});
|
|
2335
2580
|
}
|
|
2336
2581
|
if (b === "complete") {
|
|
2337
2582
|
let phase = null;
|
|
@@ -2375,26 +2620,30 @@ export default function planPiExtension(pi) {
|
|
|
2375
2620
|
return;
|
|
2376
2621
|
}
|
|
2377
2622
|
const now = nowISO();
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|
+
});
|
|
2395
2644
|
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2396
2645
|
await st.writeGenerated();
|
|
2397
|
-
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");
|
|
2398
2647
|
return;
|
|
2399
2648
|
}
|
|
2400
2649
|
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
@@ -2500,8 +2749,15 @@ export default function planPiExtension(pi) {
|
|
|
2500
2749
|
ctx.ui.notify(r.error, "warning");
|
|
2501
2750
|
return;
|
|
2502
2751
|
}
|
|
2503
|
-
const
|
|
2504
|
-
|
|
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");
|
|
2505
2761
|
return;
|
|
2506
2762
|
}
|
|
2507
2763
|
if (action === "write") {
|
|
@@ -2516,14 +2772,16 @@ export default function planPiExtension(pi) {
|
|
|
2516
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" +
|
|
2517
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" +
|
|
2518
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" +
|
|
2519
|
-
"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" +
|
|
2520
2776
|
"The reconciled handoff MUST contain at minimum:\n" +
|
|
2521
2777
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2522
2778
|
"- `Reason:` why this handoff is being written\n" +
|
|
2523
2779
|
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2524
|
-
"- `##
|
|
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" +
|
|
2525
2784
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2526
|
-
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2527
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.");
|
|
2528
2786
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2529
2787
|
return;
|
|
@@ -2649,6 +2907,15 @@ export default function planPiExtension(pi) {
|
|
|
2649
2907
|
return;
|
|
2650
2908
|
}
|
|
2651
2909
|
if (a === "load") {
|
|
2910
|
+
const st = ensureStore(ctx);
|
|
2911
|
+
let preparation;
|
|
2912
|
+
try {
|
|
2913
|
+
preparation = await st.preparePlannerSession();
|
|
2914
|
+
}
|
|
2915
|
+
catch (error) {
|
|
2916
|
+
ctx.ui.notify(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2652
2919
|
enablePlannerSession();
|
|
2653
2920
|
if (!server) {
|
|
2654
2921
|
ctx.ui.notify("Starting web server (LAN) …", "info");
|
|
@@ -2678,17 +2945,22 @@ export default function planPiExtension(pi) {
|
|
|
2678
2945
|
// triggerTurn messages), so the recap data must live here. Uses the shared
|
|
2679
2946
|
// core buildRecap so Pi and Claude Code/Codex present identical content.
|
|
2680
2947
|
let recapText = "";
|
|
2948
|
+
let plannerSkillContext = "";
|
|
2681
2949
|
try {
|
|
2682
|
-
const
|
|
2950
|
+
const plannerSkill = await st.syncPlannerSkill();
|
|
2951
|
+
await st.syncGrillMeSkill();
|
|
2683
2952
|
const srv = server;
|
|
2684
2953
|
recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2954
|
+
if (preparation.changed)
|
|
2955
|
+
recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
|
|
2956
|
+
plannerSkillContext = `[agent-only planner usage skill; do not quote in the recap]\n${plannerSkill.customized ? `${plannerSkill.message}\n` : ""}${plannerSkill.content}\n[end agent-only planner usage skill]`;
|
|
2685
2957
|
}
|
|
2686
2958
|
catch (e) {
|
|
2687
2959
|
recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
2688
2960
|
}
|
|
2689
2961
|
pi.sendMessage({
|
|
2690
2962
|
customType: "planner-resume-trigger",
|
|
2691
|
-
content: "[internal trigger — not a user command]
|
|
2963
|
+
content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill, then present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n" + plannerSkillContext + "\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
|
|
2692
2964
|
display: false,
|
|
2693
2965
|
}, {
|
|
2694
2966
|
triggerTurn: true,
|
|
@@ -2767,10 +3039,14 @@ export default function planPiExtension(pi) {
|
|
|
2767
3039
|
project.contentLanguage = normalizedContent || fallback;
|
|
2768
3040
|
project.chatLanguage = normalizedChat || fallback;
|
|
2769
3041
|
}
|
|
2770
|
-
await st.
|
|
3042
|
+
const persisted = await st.updateProject((current) => ({
|
|
3043
|
+
...current,
|
|
3044
|
+
contentLanguage: project.contentLanguage,
|
|
3045
|
+
chatLanguage: project.chatLanguage,
|
|
3046
|
+
}));
|
|
2771
3047
|
await st.writeGenerated();
|
|
2772
3048
|
return {
|
|
2773
|
-
content: [{ type: "text", text: `Saved language preferences: content=${
|
|
3049
|
+
content: [{ type: "text", text: `Saved language preferences: content=${persisted.contentLanguage || "(unset)"}, chat=${persisted.chatLanguage || "(unset)"}` }],
|
|
2774
3050
|
details: { contentLanguage: project.contentLanguage, chatLanguage: project.chatLanguage },
|
|
2775
3051
|
};
|
|
2776
3052
|
},
|
|
@@ -2783,7 +3059,7 @@ export default function planPiExtension(pi) {
|
|
|
2783
3059
|
projectName: Type.String({ description: "Concise project name/title" }),
|
|
2784
3060
|
description: Type.Optional(Type.String({ description: "Short project description" })),
|
|
2785
3061
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
2786
|
-
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." })),
|
|
2787
3063
|
}),
|
|
2788
3064
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2789
3065
|
const st = loadStore(ctx);
|
|
@@ -2814,7 +3090,6 @@ export default function planPiExtension(pi) {
|
|
|
2814
3090
|
id: createRequirementId(),
|
|
2815
3091
|
title,
|
|
2816
3092
|
description: "",
|
|
2817
|
-
status: "planned",
|
|
2818
3093
|
macroTasks: [],
|
|
2819
3094
|
linkedPhaseIds: [],
|
|
2820
3095
|
sessionInfo: [],
|
|
@@ -2837,6 +3112,7 @@ export default function planPiExtension(pi) {
|
|
|
2837
3112
|
parameters: Type.Object({
|
|
2838
3113
|
name: Type.Optional(Type.String({ description: "Project title" })),
|
|
2839
3114
|
description: Type.Optional(Type.String({ description: "Short project description" })),
|
|
3115
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
|
|
2840
3116
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
2841
3117
|
scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
|
|
2842
3118
|
outOfScope: Type.Optional(Type.Array(Type.String(), { description: "Replace out-of-scope items" })),
|
|
@@ -2850,10 +3126,34 @@ export default function planPiExtension(pi) {
|
|
|
2850
3126
|
if (!st)
|
|
2851
3127
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
2852
3128
|
const project = await st.loadProject();
|
|
3129
|
+
const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
|
|
3130
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3131
|
+
if (receivedFields.length === 0) {
|
|
3132
|
+
return mutationNoFieldsFailure({
|
|
3133
|
+
entity: "project",
|
|
3134
|
+
ref: project.name,
|
|
3135
|
+
operation: "update",
|
|
3136
|
+
mutableFields,
|
|
3137
|
+
retryCommand: "project_update with a concise description summary and the intended metadata",
|
|
3138
|
+
readBackCommand: "plan_get",
|
|
3139
|
+
descriptionFallback: {
|
|
3140
|
+
inputField: "description",
|
|
3141
|
+
storedField: "project description",
|
|
3142
|
+
descriptionRefField: "descriptionRef",
|
|
3143
|
+
},
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
2853
3146
|
if (params.name !== undefined)
|
|
2854
3147
|
project.name = params.name.trim();
|
|
2855
3148
|
if (params.description !== undefined)
|
|
2856
3149
|
project.description = params.description.trim();
|
|
3150
|
+
if (params.descriptionRef !== undefined) {
|
|
3151
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
3152
|
+
if (descriptionRef)
|
|
3153
|
+
project.descriptionRef = descriptionRef;
|
|
3154
|
+
else
|
|
3155
|
+
delete project.descriptionRef;
|
|
3156
|
+
}
|
|
2857
3157
|
if (params.goal !== undefined)
|
|
2858
3158
|
project.goal = params.goal.trim();
|
|
2859
3159
|
if (params.scope !== undefined)
|
|
@@ -2868,37 +3168,178 @@ export default function planPiExtension(pi) {
|
|
|
2868
3168
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
2869
3169
|
if (params.decisions !== undefined)
|
|
2870
3170
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
2871
|
-
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
|
+
});
|
|
2872
3193
|
await st.writeGenerated();
|
|
2873
|
-
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 } };
|
|
2874
3195
|
},
|
|
2875
3196
|
});
|
|
2876
3197
|
pi.registerTool({
|
|
2877
|
-
name: "
|
|
2878
|
-
label: "
|
|
2879
|
-
description: "
|
|
3198
|
+
name: "project_guidelines_show",
|
|
3199
|
+
label: "Project Guidelines Show",
|
|
3200
|
+
description: "Read the canonical Project Guidelines / Coding Standard section for this project. Call this when task_start or task_switch nextActions require it, and keep the guidance in working memory while doing task work.",
|
|
2880
3201
|
parameters: Type.Object({}),
|
|
2881
3202
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2882
3203
|
const st = await requirePlan(ctx);
|
|
3204
|
+
if (!st)
|
|
3205
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3206
|
+
const project = await st.loadProject();
|
|
3207
|
+
if (project.projectGuidelines.content.trim()) {
|
|
3208
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
3209
|
+
}
|
|
3210
|
+
const refreshed = await st.loadProject();
|
|
3211
|
+
const content = refreshed.projectGuidelines.content.trim();
|
|
3212
|
+
return {
|
|
3213
|
+
content: [{ type: "text", text: content
|
|
3214
|
+
? [
|
|
3215
|
+
"## Project Guidelines",
|
|
3216
|
+
content,
|
|
3217
|
+
].join("\n\n")
|
|
3218
|
+
: "No project guidelines set."
|
|
3219
|
+
}],
|
|
3220
|
+
details: {
|
|
3221
|
+
projectGuidelines: refreshed.projectGuidelines,
|
|
3222
|
+
readRecorded: Boolean(content),
|
|
3223
|
+
},
|
|
3224
|
+
};
|
|
3225
|
+
},
|
|
3226
|
+
});
|
|
3227
|
+
pi.registerTool({
|
|
3228
|
+
name: "project_guidelines_update",
|
|
3229
|
+
label: "Project Guidelines Update",
|
|
3230
|
+
description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
|
|
3231
|
+
parameters: Type.Object({
|
|
3232
|
+
content: Type.String({ description: "Full Project Guidelines markdown/text. Pass an empty string to clear the section." }),
|
|
3233
|
+
}),
|
|
3234
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3235
|
+
const st = await requirePlan(ctx);
|
|
3236
|
+
if (!st)
|
|
3237
|
+
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
3238
|
+
const project = await st.loadProject();
|
|
3239
|
+
await st.updateProject((current) => ({
|
|
3240
|
+
...current,
|
|
3241
|
+
projectGuidelines: { ...current.projectGuidelines, content: params.content.trim() },
|
|
3242
|
+
}), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
|
|
3243
|
+
await st.writeGenerated();
|
|
3244
|
+
const refreshed = await st.loadProject();
|
|
3245
|
+
return {
|
|
3246
|
+
content: [{ type: "text", text: refreshed.projectGuidelines.content
|
|
3247
|
+
? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
|
|
3248
|
+
: "Project Guidelines cleared."
|
|
3249
|
+
}],
|
|
3250
|
+
details: {
|
|
3251
|
+
updated: true,
|
|
3252
|
+
projectGuidelines: refreshed.projectGuidelines,
|
|
3253
|
+
},
|
|
3254
|
+
};
|
|
3255
|
+
},
|
|
3256
|
+
});
|
|
3257
|
+
pi.registerTool({
|
|
3258
|
+
name: "project_context_migrate",
|
|
3259
|
+
label: "Project Context Migrate",
|
|
3260
|
+
description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
|
|
3261
|
+
parameters: Type.Object({
|
|
3262
|
+
apply: Type.Optional(Type.Boolean({ description: "False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields." })),
|
|
3263
|
+
}),
|
|
3264
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3265
|
+
const st = await requirePlan(ctx);
|
|
3266
|
+
if (!st)
|
|
3267
|
+
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
3268
|
+
const preview = await st.previewLegacyProjectContextMigration();
|
|
3269
|
+
if (params.apply !== true) {
|
|
3270
|
+
return {
|
|
3271
|
+
content: [{ type: "text", text: ["Project context migration preview (no changes applied).", legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n") }],
|
|
3272
|
+
details: { applied: false, preview },
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
const result = await st.migrateLegacyProjectContext();
|
|
3276
|
+
await st.writeGenerated();
|
|
3277
|
+
return {
|
|
3278
|
+
content: [{ type: "text", text: [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n") }],
|
|
3279
|
+
details: result,
|
|
3280
|
+
};
|
|
3281
|
+
},
|
|
3282
|
+
});
|
|
3283
|
+
pi.registerTool({
|
|
3284
|
+
name: "requirement_list",
|
|
3285
|
+
label: "Requirement List",
|
|
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) {
|
|
3291
|
+
const st = await requirePlan(ctx);
|
|
3292
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
2883
3293
|
if (!st)
|
|
2884
3294
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2885
3295
|
const requirements = await st.loadRequirements();
|
|
2886
|
-
|
|
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");
|
|
2887
3318
|
return {
|
|
2888
|
-
content: [{ type: "text", text:
|
|
2889
|
-
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
|
+
},
|
|
2890
3327
|
};
|
|
2891
3328
|
},
|
|
2892
3329
|
});
|
|
2893
3330
|
pi.registerTool({
|
|
2894
3331
|
name: "requirement_create",
|
|
2895
3332
|
label: "Requirement Create",
|
|
2896
|
-
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.",
|
|
2897
3334
|
parameters: Type.Object({
|
|
2898
3335
|
title: Type.String({ description: "Requirement title" }),
|
|
2899
3336
|
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
2900
|
-
status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
|
|
2901
3337
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
|
|
3338
|
+
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3339
|
+
title: Type.String({ description: "Macro-task title" }),
|
|
3340
|
+
description: Type.Optional(Type.String({ description: "Macro-task description" })),
|
|
3341
|
+
status: Type.Optional(Type.String({ description: "Macro-task status" })),
|
|
3342
|
+
}), { description: "Initial macro tasks. IDs and timestamps are planner-managed." })),
|
|
2902
3343
|
}),
|
|
2903
3344
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2904
3345
|
const st = await requirePlan(ctx);
|
|
@@ -2910,35 +3351,55 @@ export default function planPiExtension(pi) {
|
|
|
2910
3351
|
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2911
3352
|
if (!links.ok)
|
|
2912
3353
|
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2913
|
-
const requirements = await st.loadRequirements();
|
|
2914
3354
|
const now = nowISO();
|
|
3355
|
+
let macroTasks;
|
|
3356
|
+
try {
|
|
3357
|
+
macroTasks = reconcileRequirementMacroTasks([], (params.macroTasks ?? []).map((task) => ({
|
|
3358
|
+
title: task.title,
|
|
3359
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
3360
|
+
status: task.status?.trim() ?? "planned",
|
|
3361
|
+
})), now);
|
|
3362
|
+
}
|
|
3363
|
+
catch (error) {
|
|
3364
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
3365
|
+
return { content: [{ type: "text", text: message }], details: {} };
|
|
3366
|
+
}
|
|
2915
3367
|
const requirement = {
|
|
2916
3368
|
id: createRequirementId(),
|
|
2917
3369
|
title,
|
|
2918
3370
|
description: params.description?.trim() ?? "",
|
|
2919
|
-
|
|
2920
|
-
macroTasks: [],
|
|
3371
|
+
macroTasks,
|
|
2921
3372
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
2922
3373
|
sessionInfo: [],
|
|
2923
3374
|
createdAt: now,
|
|
2924
3375
|
updatedAt: now,
|
|
2925
3376
|
};
|
|
2926
|
-
|
|
2927
|
-
|
|
3377
|
+
try {
|
|
3378
|
+
await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : error instanceof Error ? error.message : "Invalid requirement.";
|
|
3382
|
+
return { content: [{ type: "text", text: message }], details: {} };
|
|
3383
|
+
}
|
|
2928
3384
|
await st.writeGenerated();
|
|
2929
|
-
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 } };
|
|
2930
3386
|
},
|
|
2931
3387
|
});
|
|
2932
3388
|
pi.registerTool({
|
|
2933
3389
|
name: "requirement_update",
|
|
2934
3390
|
label: "Requirement Update",
|
|
2935
|
-
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.",
|
|
2936
3392
|
parameters: Type.Object({
|
|
2937
3393
|
requirementId: Type.String({ description: "Requirement ID" }),
|
|
2938
3394
|
title: Type.Optional(Type.String({ description: "Requirement title" })),
|
|
2939
3395
|
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
2940
|
-
status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
|
|
2941
3396
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
|
|
3397
|
+
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3398
|
+
id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
|
|
3399
|
+
title: Type.String({ description: "Macro-task title" }),
|
|
3400
|
+
description: Type.Optional(Type.String({ description: "Macro-task description" })),
|
|
3401
|
+
status: Type.String({ description: "Macro-task status" }),
|
|
3402
|
+
}), { description: "Replace, add, remove, or reorder macro tasks. IDs/timestamps are preserved or assigned by the planner." })),
|
|
2942
3403
|
}),
|
|
2943
3404
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2944
3405
|
const st = await requirePlan(ctx);
|
|
@@ -2948,28 +3409,48 @@ export default function planPiExtension(pi) {
|
|
|
2948
3409
|
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
2949
3410
|
if (!requirement)
|
|
2950
3411
|
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2951
|
-
if (params.title !== undefined) {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
3412
|
+
if (params.title !== undefined && !params.title.trim()) {
|
|
3413
|
+
return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
|
|
3414
|
+
}
|
|
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
|
+
});
|
|
2956
3423
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
requirement.
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
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 } };
|
|
3429
|
+
const now = nowISO();
|
|
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: {} };
|
|
3449
|
+
}
|
|
3450
|
+
await st.writeGenerated();
|
|
3451
|
+
return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
|
|
3452
|
+
},
|
|
3453
|
+
});
|
|
2973
3454
|
pi.registerTool({
|
|
2974
3455
|
name: "requirement_delete",
|
|
2975
3456
|
label: "Requirement Delete",
|
|
@@ -2981,32 +3462,134 @@ export default function planPiExtension(pi) {
|
|
|
2981
3462
|
const st = await requirePlan(ctx);
|
|
2982
3463
|
if (!st)
|
|
2983
3464
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
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 } };
|
|
2988
3473
|
}
|
|
2989
|
-
await st.saveRequirements({ requirements: next });
|
|
2990
3474
|
await st.writeGenerated();
|
|
2991
|
-
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 } };
|
|
3476
|
+
},
|
|
3477
|
+
});
|
|
3478
|
+
pi.registerTool({
|
|
3479
|
+
name: "idea_list", label: "Idea List", description: "List rollup-independent Ideas Inbox entries.", parameters: Type.Object({}),
|
|
3480
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3481
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const ideas = (await st.loadIdeas()).ideas; return { content: [{ type: "text", text: ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas" }], details: { ideas } }; },
|
|
3482
|
+
});
|
|
3483
|
+
pi.registerTool({
|
|
3484
|
+
name: "idea_show", label: "Idea Show", description: "Show an idea by I-number, short ID, UUID, or title.", parameters: Type.Object({ idea: Type.String() }),
|
|
3485
|
+
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3486
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); return idea ? { content: [{ type: "text", text: `${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}` }], details: { idea } } : { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND" } }; },
|
|
3487
|
+
});
|
|
3488
|
+
pi.registerTool({
|
|
3489
|
+
name: "idea_create", label: "Idea Create", description: "Create an independent Ideas Inbox entry.", parameters: Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) }),
|
|
3490
|
+
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3491
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = await st.createIdea({ title: params.title, ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}` }], details: { idea, created: true } }; },
|
|
3492
|
+
});
|
|
3493
|
+
pi.registerTool({
|
|
3494
|
+
name: "idea_update", label: "Idea Update", description: "Update an Ideas Inbox title or description.", parameters: Type.Object({ idea: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }),
|
|
3495
|
+
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3496
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
|
|
3497
|
+
return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
|
|
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 } }; },
|
|
3499
|
+
});
|
|
3500
|
+
pi.registerTool({
|
|
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() }),
|
|
3502
|
+
async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.confirmed)
|
|
3503
|
+
return { content: [{ type: "text", text: "Idea deletion requires confirmed=true after explicit user confirmation." }], details: { errorCode: "CONFIRMATION_REQUIRED", deleted: false } }; const st = await requirePlan(ctx); if (!st)
|
|
3504
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
|
|
3505
|
+
return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", deleted: false } }; await st.deleteIdea(current.id); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea deleted: ${formatIdeaRef(current.number)}.` }], details: { deleted: true, ideaRef: formatIdeaRef(current.number) } }; },
|
|
3506
|
+
});
|
|
3507
|
+
pi.registerTool({
|
|
3508
|
+
name: "idea_promotion_begin", label: "Idea Promotion Begin", description: "Begin a grill-me idea promotion discussion without persisting a target or promotion.", parameters: Type.Object({ idea: Type.String(), targetType: Type.Union([Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]) }),
|
|
3509
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3510
|
+
const st = await requirePlan(ctx);
|
|
3511
|
+
if (!st)
|
|
3512
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3513
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea);
|
|
3514
|
+
if (!idea)
|
|
3515
|
+
return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND" } };
|
|
3516
|
+
if (idea.promotion)
|
|
3517
|
+
return { content: [{ type: "text", text: `${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.` }], details: { errorCode: "ALREADY_PROMOTED", idea } };
|
|
3518
|
+
const features = (await st.loadFeatures()).features;
|
|
3519
|
+
const phases = await st.loadAllPhases();
|
|
3520
|
+
const recommendedFeature = features[0];
|
|
3521
|
+
const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
|
|
3522
|
+
const recommendation = params.targetType === "feature"
|
|
3523
|
+
? "Create a new feature after the discussion."
|
|
3524
|
+
: params.targetType === "phase"
|
|
3525
|
+
? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
|
|
3526
|
+
: recommendedPhase ? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.` : "Create and confirm a parent feature and phase before promoting to a task.";
|
|
3527
|
+
return { content: [{ type: "text", text: `Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}` }], details: { idea, targetType: params.targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target using feature_create, phase_create, or task_create.", "Call idea_promotion_finalize with discussionCompleted=true and confirmed=true."] } };
|
|
2992
3528
|
},
|
|
2993
3529
|
});
|
|
3530
|
+
pi.registerTool({
|
|
3531
|
+
name: "idea_promotion_finalize", label: "Idea Promotion Finalize", description: "Record an idea promotion after grill-me discussion, explicit confirmation, and successful normal target creation.", parameters: Type.Object({ idea: Type.String(), targetType: Type.Union([Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]), targetRef: Type.String(), discussionCompleted: Type.Boolean(), confirmed: Type.Boolean() }),
|
|
3532
|
+
async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.discussionCompleted || !params.confirmed)
|
|
3533
|
+
return { content: [{ type: "text", text: "Promotion requires discussionCompleted=true and confirmed=true; no promotion was persisted." }], details: { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false } }; const st = await requirePlan(ctx); if (!st)
|
|
3534
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!idea)
|
|
3535
|
+
return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", promoted: false } }; const features = (await st.loadFeatures()).features; const phases = await st.loadAllPhases(); let target; if (params.targetType === "feature") {
|
|
3536
|
+
const resolved = resolveFeatureRefStrict(features, params.targetRef);
|
|
3537
|
+
target = resolved.ok ? { id: resolved.feature.id } : undefined;
|
|
3538
|
+
}
|
|
3539
|
+
else if (params.targetType === "phase") {
|
|
3540
|
+
const phase = findPhaseByRef(phases, features, params.targetRef);
|
|
3541
|
+
target = phase ? { id: phase.id } : undefined;
|
|
3542
|
+
}
|
|
3543
|
+
else {
|
|
3544
|
+
const found = findTaskByRef(phases, features, params.targetRef);
|
|
3545
|
+
target = found ? { id: found.task.id } : undefined;
|
|
3546
|
+
} if (!target)
|
|
3547
|
+
return { content: [{ type: "text", text: `Confirmed ${params.targetType} target not found: ${params.targetRef}; no promotion was persisted.` }], details: { errorCode: "TARGET_NOT_FOUND", promoted: false } }; const promoted = await st.promoteIdea(idea.id, { targetType: params.targetType, targetId: target.id }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}` }], details: { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef } }; },
|
|
3548
|
+
});
|
|
2994
3549
|
pi.registerTool({
|
|
2995
3550
|
name: "plan_get",
|
|
2996
3551
|
label: "Plan Get",
|
|
2997
|
-
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.",
|
|
2998
3553
|
parameters: Type.Object({}),
|
|
2999
3554
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
3000
3555
|
const st = await requirePlan(ctx);
|
|
3001
3556
|
if (!st)
|
|
3002
3557
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
3003
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 }]);
|
|
3004
3571
|
return {
|
|
3005
|
-
content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements` }],
|
|
3006
|
-
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 },
|
|
3007
3574
|
};
|
|
3008
3575
|
},
|
|
3009
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
|
+
});
|
|
3010
3593
|
pi.registerTool({
|
|
3011
3594
|
name: "plan_render",
|
|
3012
3595
|
label: "Plan Render",
|
|
@@ -3089,9 +3672,11 @@ export default function planPiExtension(pi) {
|
|
|
3089
3672
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3090
3673
|
const list = await st.listHandoffs();
|
|
3091
3674
|
if (list.length === 0)
|
|
3092
|
-
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 } };
|
|
3093
|
-
const
|
|
3094
|
-
|
|
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: [] } };
|
|
3676
|
+
const result = boundedPage(list, 1, 10);
|
|
3677
|
+
const { items, ...pageInfo } = result;
|
|
3678
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
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 } };
|
|
3095
3680
|
},
|
|
3096
3681
|
});
|
|
3097
3682
|
pi.registerTool({
|
|
@@ -3182,23 +3767,28 @@ export default function planPiExtension(pi) {
|
|
|
3182
3767
|
pi.registerTool({
|
|
3183
3768
|
name: "handoff_list",
|
|
3184
3769
|
label: "Handoff List",
|
|
3185
|
-
description: "List
|
|
3186
|
-
parameters: Type.Object({
|
|
3187
|
-
|
|
3770
|
+
description: "List phases with active entity-scoped handoffs as compact summary-only pages. Full bodies are never embedded; call handoff_show for one exact phase.",
|
|
3771
|
+
parameters: Type.Object({
|
|
3772
|
+
page: Type.Optional(Type.Integer({ minimum: 1, description: "1-based page number (default 1)." })),
|
|
3773
|
+
pageSize: Type.Optional(Type.Integer({ minimum: 1, maximum: 25, description: "Summaries per page (default 10, maximum 25)." })),
|
|
3774
|
+
}),
|
|
3775
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3188
3776
|
const st = await requirePlan(ctx);
|
|
3189
3777
|
if (!st)
|
|
3190
3778
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3191
3779
|
const list = await st.listHandoffs();
|
|
3192
3780
|
if (list.length === 0)
|
|
3193
|
-
return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
|
|
3194
|
-
const
|
|
3195
|
-
|
|
3781
|
+
return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
|
|
3782
|
+
const result = boundedPage(list, params.page ?? 1, params.pageSize ?? 10);
|
|
3783
|
+
const { items, ...pageInfo } = result;
|
|
3784
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
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 } };
|
|
3196
3786
|
},
|
|
3197
3787
|
});
|
|
3198
3788
|
pi.registerTool({
|
|
3199
3789
|
name: "handoff_show",
|
|
3200
3790
|
label: "Handoff Show",
|
|
3201
|
-
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.",
|
|
3202
3792
|
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
|
|
3203
3793
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3204
3794
|
const st = await requirePlan(ctx);
|
|
@@ -3207,16 +3797,100 @@ export default function planPiExtension(pi) {
|
|
|
3207
3797
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3208
3798
|
if (!r.ok)
|
|
3209
3799
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3210
|
-
const
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3800
|
+
const phase = await st.loadPhase(r.phase.id);
|
|
3801
|
+
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
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.";
|
|
3844
|
+
return {
|
|
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 },
|
|
3847
|
+
};
|
|
3848
|
+
},
|
|
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
|
+
}
|
|
3214
3888
|
},
|
|
3215
3889
|
});
|
|
3216
3890
|
pi.registerTool({
|
|
3217
3891
|
name: "handoff_prepare",
|
|
3218
3892
|
label: "Handoff Prepare",
|
|
3219
|
-
description: "Audit one exact phase before writing a handoff. Returns the
|
|
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.",
|
|
3220
3894
|
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3221
3895
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3222
3896
|
const st = await requirePlan(ctx);
|
|
@@ -3227,19 +3901,36 @@ export default function planPiExtension(pi) {
|
|
|
3227
3901
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3228
3902
|
try {
|
|
3229
3903
|
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3904
|
+
const { handoff, ...auditDetails } = audit;
|
|
3905
|
+
const boundedExisting = boundedHandoffForTransport(handoff);
|
|
3230
3906
|
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
3231
|
-
const existing =
|
|
3907
|
+
const existing = boundedExisting.content.trim() || "(none)";
|
|
3232
3908
|
return {
|
|
3233
3909
|
content: [{ type: "text", text: [
|
|
3234
3910
|
`Handoff preparation audit for ${r.compositeRef}`,
|
|
3235
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
|
+
"",
|
|
3236
3918
|
"Done tasks missing durable completion/verification evidence:",
|
|
3237
3919
|
missing,
|
|
3238
3920
|
"",
|
|
3921
|
+
`Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
|
|
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}`),
|
|
3928
|
+
`Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
|
|
3929
|
+
"",
|
|
3239
3930
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
3240
3931
|
existing,
|
|
3241
3932
|
].join("\n") }],
|
|
3242
|
-
details: { phaseRef: r.compositeRef, ...
|
|
3933
|
+
details: { phaseRef: r.compositeRef, ...auditDetails, existingHandoffLength: boundedExisting.fullLength, existingHandoffTruncated: boundedExisting.truncated },
|
|
3243
3934
|
};
|
|
3244
3935
|
}
|
|
3245
3936
|
catch (error) {
|
|
@@ -3251,15 +3942,40 @@ export default function planPiExtension(pi) {
|
|
|
3251
3942
|
pi.registerTool({
|
|
3252
3943
|
name: "handoff_write",
|
|
3253
3944
|
label: "Handoff Write",
|
|
3254
|
-
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.",
|
|
3255
3946
|
parameters: Type.Object({
|
|
3256
3947
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3257
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." })),
|
|
3258
3950
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3259
3951
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3260
3952
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
3261
3953
|
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3262
3954
|
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3955
|
+
completenessAudit: Type.Optional(Type.Object({
|
|
3956
|
+
version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
3957
|
+
entries: Type.Array(Type.Object({
|
|
3958
|
+
category: Type.String({ description: `One required category id from handoff_prepare: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
|
|
3959
|
+
status: Type.Union([Type.Literal("captured"), Type.Literal("not-applicable")]),
|
|
3960
|
+
detail: Type.String({ description: "Substantive captured detail or a substantive reason why the category is not applicable." }),
|
|
3961
|
+
})),
|
|
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
|
+
})),
|
|
3975
|
+
supportingDocuments: Type.Optional(Type.Array(Type.Object({
|
|
3976
|
+
path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
|
|
3977
|
+
description: Type.String({ description: "What the document contains and why the next agent needs it." }),
|
|
3978
|
+
}))),
|
|
3263
3979
|
taskUpdates: Type.Optional(Type.Array(Type.Object({
|
|
3264
3980
|
taskRef: Type.String(),
|
|
3265
3981
|
completionSummary: Type.String(),
|
|
@@ -3306,7 +4022,9 @@ export default function planPiExtension(pi) {
|
|
|
3306
4022
|
if (!params.confirmed)
|
|
3307
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 } };
|
|
3308
4024
|
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3309
|
-
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"] } };
|
|
3310
4028
|
const phases = await st.loadAllPhases();
|
|
3311
4029
|
const features = (await st.loadFeatures()).features;
|
|
3312
4030
|
const taskUpdates = [];
|
|
@@ -3325,9 +4043,13 @@ export default function planPiExtension(pi) {
|
|
|
3325
4043
|
}
|
|
3326
4044
|
try {
|
|
3327
4045
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
4046
|
+
reason: params.reason.trim(),
|
|
3328
4047
|
content: body,
|
|
3329
4048
|
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3330
4049
|
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
4050
|
+
...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
|
|
4051
|
+
...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
|
|
4052
|
+
...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
|
|
3331
4053
|
contextSync: {
|
|
3332
4054
|
taskUpdates,
|
|
3333
4055
|
...(params.phaseUpdate ? { phaseUpdate: {
|
|
@@ -3341,9 +4063,28 @@ export default function planPiExtension(pi) {
|
|
|
3341
4063
|
},
|
|
3342
4064
|
});
|
|
3343
4065
|
await st.writeGenerated();
|
|
3344
|
-
return {
|
|
4066
|
+
return {
|
|
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.` }],
|
|
4068
|
+
details: {
|
|
4069
|
+
phaseRef: r.compositeRef,
|
|
4070
|
+
phaseId: r.phase.id,
|
|
4071
|
+
updatedTaskIds: result.updatedTaskIds,
|
|
4072
|
+
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
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
|
+
],
|
|
4082
|
+
},
|
|
4083
|
+
};
|
|
3345
4084
|
}
|
|
3346
4085
|
catch (error) {
|
|
4086
|
+
if (error instanceof HandoffContractError)
|
|
4087
|
+
return handoffContractFailure(error);
|
|
3347
4088
|
const message = error instanceof Error ? error.message : String(error);
|
|
3348
4089
|
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
|
|
3349
4090
|
}
|
|
@@ -3398,7 +4139,7 @@ export default function planPiExtension(pi) {
|
|
|
3398
4139
|
pi.registerTool({
|
|
3399
4140
|
name: "feature_list",
|
|
3400
4141
|
label: "Feature List",
|
|
3401
|
-
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
|
|
4142
|
+
description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
|
|
3402
4143
|
parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
|
|
3403
4144
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3404
4145
|
const st = await requirePlan(ctx);
|
|
@@ -3411,7 +4152,7 @@ export default function planPiExtension(pi) {
|
|
|
3411
4152
|
const phases = await st.loadAllPhases();
|
|
3412
4153
|
const summary = features.map((f) => {
|
|
3413
4154
|
const fp = phases.filter((p) => p.featureId === f.id);
|
|
3414
|
-
return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
4155
|
+
return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}${f.priority > 0 ? `; priority ${f.priority}` : ""}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
3415
4156
|
}).join("\n");
|
|
3416
4157
|
return { content: [{ type: "text", text: summary || "No features" }], details: {} };
|
|
3417
4158
|
},
|
|
@@ -3419,13 +4160,14 @@ export default function planPiExtension(pi) {
|
|
|
3419
4160
|
pi.registerTool({
|
|
3420
4161
|
name: "feature_get",
|
|
3421
4162
|
label: "Feature Get",
|
|
3422
|
-
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.",
|
|
3423
4164
|
parameters: Type.Object({
|
|
3424
4165
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3425
4166
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
3426
4167
|
}),
|
|
3427
4168
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3428
4169
|
const st = await requirePlan(ctx);
|
|
4170
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
3429
4171
|
if (!st)
|
|
3430
4172
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3431
4173
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3434,15 +4176,30 @@ export default function planPiExtension(pi) {
|
|
|
3434
4176
|
if (!resolvedFeature.ok)
|
|
3435
4177
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3436
4178
|
const feature = resolvedFeature.feature;
|
|
3437
|
-
if (params.full)
|
|
3438
|
-
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3439
4179
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3440
4180
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3441
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"}` : ""})`;
|
|
4182
|
+
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
3442
4183
|
const requirementsBlock = linkedRequirements.length > 0
|
|
3443
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4184
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
3444
4185
|
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3445
|
-
|
|
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
|
+
},
|
|
4200
|
+
};
|
|
4201
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
|
|
4202
|
+
return result;
|
|
3446
4203
|
},
|
|
3447
4204
|
});
|
|
3448
4205
|
pi.registerTool({
|
|
@@ -3458,44 +4215,46 @@ export default function planPiExtension(pi) {
|
|
|
3458
4215
|
const st = await requirePlan(ctx);
|
|
3459
4216
|
if (!st)
|
|
3460
4217
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
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 };
|
|
3496
4257
|
});
|
|
3497
|
-
await st.writeGenerated();
|
|
3498
|
-
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3499
4258
|
},
|
|
3500
4259
|
});
|
|
3501
4260
|
pi.registerTool({
|
|
@@ -3505,6 +4264,7 @@ export default function planPiExtension(pi) {
|
|
|
3505
4264
|
parameters: Type.Object({
|
|
3506
4265
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3507
4266
|
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
4267
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
3508
4268
|
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3509
4269
|
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3510
4270
|
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
@@ -3518,16 +4278,41 @@ export default function planPiExtension(pi) {
|
|
|
3518
4278
|
if (!resolvedFeature.ok)
|
|
3519
4279
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3520
4280
|
const featureId = resolvedFeature.feature.id;
|
|
4281
|
+
const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
|
|
4282
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4283
|
+
if (receivedFields.length === 0) {
|
|
4284
|
+
return mutationNoFieldsFailure({
|
|
4285
|
+
entity: "feature",
|
|
4286
|
+
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4287
|
+
operation: "discuss",
|
|
4288
|
+
mutableFields,
|
|
4289
|
+
retryCommand: `feature_discuss ${formatFeatureRef(resolvedFeature.feature.number)}`,
|
|
4290
|
+
readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
|
|
4291
|
+
descriptionFallback: {
|
|
4292
|
+
entityId: featureId,
|
|
4293
|
+
inputField: "description",
|
|
4294
|
+
storedField: "feature description",
|
|
4295
|
+
descriptionRefField: "descriptionRef",
|
|
4296
|
+
},
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
3521
4299
|
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3522
4300
|
const feature = doc.features.find((f) => f.id === featureId);
|
|
3523
4301
|
if (!feature)
|
|
3524
4302
|
return doc;
|
|
3525
4303
|
if (params.description !== undefined)
|
|
3526
|
-
feature.description = params.description;
|
|
4304
|
+
feature.description = params.description.trim();
|
|
4305
|
+
if (params.descriptionRef !== undefined) {
|
|
4306
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4307
|
+
if (descriptionRef)
|
|
4308
|
+
feature.descriptionRef = descriptionRef;
|
|
4309
|
+
else
|
|
4310
|
+
delete feature.descriptionRef;
|
|
4311
|
+
}
|
|
3527
4312
|
if (params.workDone !== undefined)
|
|
3528
|
-
feature.workDone = params.workDone;
|
|
4313
|
+
feature.workDone = params.workDone.trim();
|
|
3529
4314
|
if (params.workRemaining !== undefined)
|
|
3530
|
-
feature.workRemaining = params.workRemaining;
|
|
4315
|
+
feature.workRemaining = params.workRemaining.trim();
|
|
3531
4316
|
if (params.dependencies !== undefined)
|
|
3532
4317
|
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3533
4318
|
feature.discussedAt = nowISO();
|
|
@@ -3540,18 +4325,19 @@ export default function planPiExtension(pi) {
|
|
|
3540
4325
|
if (!feature)
|
|
3541
4326
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3542
4327
|
await st.writeGenerated();
|
|
3543
|
-
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}
|
|
4328
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...feature, discussed: true, updatedFields: receivedFields } };
|
|
3544
4329
|
},
|
|
3545
4330
|
});
|
|
3546
4331
|
pi.registerTool({
|
|
3547
4332
|
name: "feature_update",
|
|
3548
4333
|
label: "Feature Update",
|
|
3549
|
-
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.",
|
|
3550
4335
|
parameters: Type.Object({
|
|
3551
4336
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3552
4337
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
3553
4338
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
3554
|
-
|
|
4339
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature 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." })),
|
|
3555
4341
|
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
3556
4342
|
endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
3557
4343
|
workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
|
|
@@ -3564,7 +4350,7 @@ export default function planPiExtension(pi) {
|
|
|
3564
4350
|
rationale: Type.String(),
|
|
3565
4351
|
implementationNotes: Type.String(),
|
|
3566
4352
|
acceptedAt: Type.String(),
|
|
3567
|
-
}), { description: "
|
|
4353
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
3568
4354
|
}),
|
|
3569
4355
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3570
4356
|
const st = await requirePlan(ctx);
|
|
@@ -3578,25 +4364,39 @@ export default function planPiExtension(pi) {
|
|
|
3578
4364
|
if (params.status !== undefined) {
|
|
3579
4365
|
return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
3580
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
|
+
}
|
|
3581
4374
|
const mutableFields = [
|
|
3582
4375
|
"name",
|
|
3583
4376
|
"description",
|
|
4377
|
+
"descriptionRef",
|
|
3584
4378
|
"startDate",
|
|
3585
4379
|
"endDate",
|
|
3586
4380
|
"workDone",
|
|
3587
4381
|
"workRemaining",
|
|
3588
4382
|
"priority",
|
|
3589
|
-
"acceptedDecisions",
|
|
3590
4383
|
];
|
|
3591
4384
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3592
4385
|
if (receivedFields.length === 0) {
|
|
3593
|
-
return {
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
4386
|
+
return mutationNoFieldsFailure({
|
|
4387
|
+
entity: "feature",
|
|
4388
|
+
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4389
|
+
operation: "update",
|
|
4390
|
+
mutableFields,
|
|
4391
|
+
retryCommand: `feature_update ${formatFeatureRef(resolvedFeature.feature.number)}`,
|
|
4392
|
+
readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
|
|
4393
|
+
descriptionFallback: {
|
|
4394
|
+
entityId: featureId,
|
|
4395
|
+
inputField: "description",
|
|
4396
|
+
storedField: "feature description",
|
|
4397
|
+
descriptionRefField: "descriptionRef",
|
|
4398
|
+
},
|
|
4399
|
+
});
|
|
3600
4400
|
}
|
|
3601
4401
|
let foundDoc;
|
|
3602
4402
|
try {
|
|
@@ -3605,21 +4405,26 @@ export default function planPiExtension(pi) {
|
|
|
3605
4405
|
if (!feature)
|
|
3606
4406
|
return doc; // not found: no-op, handled below
|
|
3607
4407
|
if (params.name !== undefined)
|
|
3608
|
-
feature.name = params.name;
|
|
4408
|
+
feature.name = params.name.trim();
|
|
3609
4409
|
if (params.description !== undefined)
|
|
3610
|
-
feature.description = params.description;
|
|
4410
|
+
feature.description = params.description.trim();
|
|
4411
|
+
if (params.descriptionRef !== undefined) {
|
|
4412
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
4413
|
+
if (descriptionRef)
|
|
4414
|
+
feature.descriptionRef = descriptionRef;
|
|
4415
|
+
else
|
|
4416
|
+
delete feature.descriptionRef;
|
|
4417
|
+
}
|
|
3611
4418
|
if (params.workDone !== undefined)
|
|
3612
|
-
feature.workDone = params.workDone;
|
|
4419
|
+
feature.workDone = params.workDone.trim();
|
|
3613
4420
|
if (params.workRemaining !== undefined)
|
|
3614
|
-
feature.workRemaining = params.workRemaining;
|
|
4421
|
+
feature.workRemaining = params.workRemaining.trim();
|
|
3615
4422
|
if (params.startDate !== undefined)
|
|
3616
|
-
feature.startDate = params.startDate;
|
|
4423
|
+
feature.startDate = params.startDate.trim();
|
|
3617
4424
|
if (params.endDate !== undefined)
|
|
3618
|
-
feature.endDate = params.endDate;
|
|
4425
|
+
feature.endDate = params.endDate.trim();
|
|
3619
4426
|
if (params.priority !== undefined)
|
|
3620
4427
|
feature.priority = params.priority;
|
|
3621
|
-
if (params.acceptedDecisions !== undefined)
|
|
3622
|
-
feature.acceptedDecisions = params.acceptedDecisions;
|
|
3623
4428
|
feature.updatedAt = nowISO();
|
|
3624
4429
|
return doc;
|
|
3625
4430
|
});
|
|
@@ -3658,22 +4463,24 @@ export default function planPiExtension(pi) {
|
|
|
3658
4463
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3659
4464
|
const featureId = resolvedFeature.feature.id;
|
|
3660
4465
|
let deleted = false;
|
|
3661
|
-
await st.updateFeatures((doc) => {
|
|
3662
|
-
const before = doc.features.length;
|
|
3663
|
-
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3664
|
-
if (doc.features.length !== before)
|
|
3665
|
-
deleted = true;
|
|
3666
|
-
return doc;
|
|
3667
|
-
});
|
|
3668
4466
|
let cascadeCount = 0;
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
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
|
+
}
|
|
3674
4481
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
4482
|
+
await st.writeGenerated();
|
|
4483
|
+
});
|
|
3677
4484
|
return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
|
|
3678
4485
|
},
|
|
3679
4486
|
});
|
|
@@ -3681,7 +4488,7 @@ export default function planPiExtension(pi) {
|
|
|
3681
4488
|
pi.registerTool({
|
|
3682
4489
|
name: "phase_list",
|
|
3683
4490
|
label: "Phase List",
|
|
3684
|
-
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
4491
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3685
4492
|
parameters: Type.Object({
|
|
3686
4493
|
featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
|
|
3687
4494
|
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
@@ -3704,7 +4511,7 @@ export default function planPiExtension(pi) {
|
|
|
3704
4511
|
const summary = phases.map((p) => {
|
|
3705
4512
|
const fNum = featureNumberOfPhase(p, features);
|
|
3706
4513
|
const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
|
|
3707
|
-
return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
|
|
4514
|
+
return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}${p.priority > 0 ? `; priority ${p.priority}` : ""}; ${p.tasks.length} tasks)${fTag}`;
|
|
3708
4515
|
}).join("\n");
|
|
3709
4516
|
return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
|
|
3710
4517
|
},
|
|
@@ -3712,28 +4519,44 @@ export default function planPiExtension(pi) {
|
|
|
3712
4519
|
pi.registerTool({
|
|
3713
4520
|
name: "phase_get",
|
|
3714
4521
|
label: "Phase Get",
|
|
3715
|
-
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.",
|
|
3716
4523
|
parameters: Type.Object({
|
|
3717
4524
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3718
4525
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
3719
4526
|
}),
|
|
3720
4527
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3721
4528
|
const st = await requirePlan(ctx);
|
|
4529
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
3722
4530
|
if (!st)
|
|
3723
4531
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3724
4532
|
const features = (await st.loadFeatures()).features;
|
|
3725
4533
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3726
4534
|
if (!phase)
|
|
3727
4535
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3728
|
-
if (params.full)
|
|
3729
|
-
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
3730
4536
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3731
4537
|
const reqCount = linkedRequirements.length;
|
|
3732
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"}` : ""})`;
|
|
4539
|
+
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
3733
4540
|
const requirementsBlock = reqCount > 0
|
|
3734
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
4541
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
3735
4542
|
: "";
|
|
3736
|
-
|
|
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
|
+
},
|
|
4557
|
+
};
|
|
4558
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
|
|
4559
|
+
return result;
|
|
3737
4560
|
},
|
|
3738
4561
|
});
|
|
3739
4562
|
pi.registerTool({
|
|
@@ -3768,7 +4591,7 @@ export default function planPiExtension(pi) {
|
|
|
3768
4591
|
if (!existingFeature)
|
|
3769
4592
|
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3770
4593
|
let phase;
|
|
3771
|
-
await withFeatureLock(featureId, async () => {
|
|
4594
|
+
await st.runBatch(() => withFeatureLock(featureId, async () => {
|
|
3772
4595
|
const id = createPhaseId();
|
|
3773
4596
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
3774
4597
|
const priority = await st.nextPriority("phase", featureId);
|
|
@@ -3784,7 +4607,7 @@ export default function planPiExtension(pi) {
|
|
|
3784
4607
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
3785
4608
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3786
4609
|
createdAt: now, updatedAt: now,
|
|
3787
|
-
handoff: "", handoffUpdatedAt: "",
|
|
4610
|
+
handoff: "", handoffUpdatedAt: "", handoffAudit: null,
|
|
3788
4611
|
handoffReadAt: "",
|
|
3789
4612
|
handoffHistory: [],
|
|
3790
4613
|
statusLog: [],
|
|
@@ -3801,23 +4624,104 @@ export default function planPiExtension(pi) {
|
|
|
3801
4624
|
return doc;
|
|
3802
4625
|
});
|
|
3803
4626
|
await st.writeGenerated();
|
|
3804
|
-
});
|
|
4627
|
+
}));
|
|
3805
4628
|
if (!phase)
|
|
3806
4629
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
3807
4630
|
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
3808
4631
|
return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3809
4632
|
},
|
|
3810
4633
|
});
|
|
4634
|
+
pi.registerTool({
|
|
4635
|
+
name: "phase_discuss",
|
|
4636
|
+
label: "Phase Discuss",
|
|
4637
|
+
description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
|
|
4638
|
+
parameters: Type.Object({
|
|
4639
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
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" })),
|
|
4644
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
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
|
+
});
|
|
3811
4714
|
pi.registerTool({
|
|
3812
4715
|
name: "phase_update",
|
|
3813
4716
|
label: "Phase Update",
|
|
3814
|
-
description: "Update one or more
|
|
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.",
|
|
3815
4718
|
parameters: Type.Object({
|
|
3816
4719
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3817
4720
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3818
|
-
status: Type.Optional(Type.String({ description: "
|
|
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." })),
|
|
3819
4722
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
3820
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." })),
|
|
3821
4725
|
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
3822
4726
|
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
3823
4727
|
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
|
|
@@ -3833,7 +4737,7 @@ export default function planPiExtension(pi) {
|
|
|
3833
4737
|
rationale: Type.String(),
|
|
3834
4738
|
implementationNotes: Type.String(),
|
|
3835
4739
|
acceptedAt: Type.String(),
|
|
3836
|
-
}), { description: "
|
|
4740
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
3837
4741
|
completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
|
|
3838
4742
|
}),
|
|
3839
4743
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -3855,34 +4759,33 @@ export default function planPiExtension(pi) {
|
|
|
3855
4759
|
if (params.status !== undefined) {
|
|
3856
4760
|
return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
3857
4761
|
}
|
|
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"];
|
|
4770
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4771
|
+
if (receivedFields.length === 0) {
|
|
4772
|
+
return mutationNoFieldsFailure({
|
|
4773
|
+
entity: "phase",
|
|
4774
|
+
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4775
|
+
operation: "update",
|
|
4776
|
+
mutableFields,
|
|
4777
|
+
retryCommand: `phase_update ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
|
|
4778
|
+
readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
|
|
4779
|
+
descriptionFallback: {
|
|
4780
|
+
entityId: phaseId,
|
|
4781
|
+
inputField: "description",
|
|
4782
|
+
storedField: "phase description",
|
|
4783
|
+
descriptionRefField: "descriptionRef",
|
|
4784
|
+
},
|
|
4785
|
+
});
|
|
4786
|
+
}
|
|
3858
4787
|
const prevFeatureId = phase.featureId;
|
|
3859
4788
|
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3860
|
-
if (params.title !== undefined) {
|
|
3861
|
-
phase.title = params.title;
|
|
3862
|
-
phase.slug = normalizeSlug(params.title);
|
|
3863
|
-
}
|
|
3864
|
-
if (params.summary !== undefined)
|
|
3865
|
-
phase.summary = params.summary;
|
|
3866
|
-
if (params.description !== undefined)
|
|
3867
|
-
phase.description = params.description;
|
|
3868
|
-
if (params.priority !== undefined)
|
|
3869
|
-
phase.priority = params.priority;
|
|
3870
|
-
if (params.goals !== undefined)
|
|
3871
|
-
phase.goals = params.goals;
|
|
3872
|
-
if (params.nonGoals !== undefined)
|
|
3873
|
-
phase.nonGoals = params.nonGoals;
|
|
3874
|
-
if (params.dependencies !== undefined)
|
|
3875
|
-
phase.dependencies = params.dependencies;
|
|
3876
|
-
if (params.risks !== undefined)
|
|
3877
|
-
phase.risks = params.risks;
|
|
3878
|
-
if (params.openQuestions !== undefined)
|
|
3879
|
-
phase.openQuestions = params.openQuestions;
|
|
3880
|
-
if (params.decisions !== undefined)
|
|
3881
|
-
phase.decisions = params.decisions;
|
|
3882
|
-
if (params.acceptedDecisions !== undefined)
|
|
3883
|
-
phase.acceptedDecisions = params.acceptedDecisions;
|
|
3884
|
-
if (params.completionCriteria !== undefined)
|
|
3885
|
-
phase.completionCriteria = params.completionCriteria;
|
|
3886
4789
|
if (params.featureId !== undefined) {
|
|
3887
4790
|
if (params.featureId === "") {
|
|
3888
4791
|
nextFeatureIdForUpdate = undefined;
|
|
@@ -3893,24 +4796,60 @@ export default function planPiExtension(pi) {
|
|
|
3893
4796
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3894
4797
|
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3895
4798
|
}
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
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);
|
|
3904
4835
|
}
|
|
3905
|
-
return
|
|
4836
|
+
return document;
|
|
3906
4837
|
});
|
|
3907
4838
|
}
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
await st.writeGenerated();
|
|
4839
|
+
await st.writeGenerated();
|
|
4840
|
+
});
|
|
4841
|
+
phase = persistedPhase;
|
|
3912
4842
|
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
3913
|
-
|
|
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 } : {}) } };
|
|
3914
4853
|
},
|
|
3915
4854
|
});
|
|
3916
4855
|
pi.registerTool({
|
|
@@ -3948,7 +4887,7 @@ export default function planPiExtension(pi) {
|
|
|
3948
4887
|
implementationNotes: params.implementationNotes.trim(),
|
|
3949
4888
|
acceptedAt,
|
|
3950
4889
|
};
|
|
3951
|
-
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
4890
|
+
await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
3952
4891
|
let featureWritten = false;
|
|
3953
4892
|
try {
|
|
3954
4893
|
await st.updateFeatures((doc) => {
|
|
@@ -3979,11 +4918,128 @@ export default function planPiExtension(pi) {
|
|
|
3979
4918
|
}
|
|
3980
4919
|
throw error;
|
|
3981
4920
|
}
|
|
3982
|
-
});
|
|
4921
|
+
}));
|
|
3983
4922
|
await st.writeGenerated();
|
|
3984
4923
|
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
3985
4924
|
},
|
|
3986
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
|
+
});
|
|
3987
5043
|
pi.registerTool({
|
|
3988
5044
|
name: "phase_delete",
|
|
3989
5045
|
label: "Phase Delete",
|
|
@@ -4007,18 +5063,20 @@ export default function planPiExtension(pi) {
|
|
|
4007
5063
|
catch {
|
|
4008
5064
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4009
5065
|
}
|
|
4010
|
-
await st.
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
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
|
+
});
|
|
4022
5080
|
return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
|
|
4023
5081
|
},
|
|
4024
5082
|
});
|
|
@@ -4026,7 +5084,7 @@ export default function planPiExtension(pi) {
|
|
|
4026
5084
|
pi.registerTool({
|
|
4027
5085
|
name: "task_list",
|
|
4028
5086
|
label: "Task List",
|
|
4029
|
-
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
5087
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
4030
5088
|
parameters: Type.Object({
|
|
4031
5089
|
featureRef: Type.Optional(Type.String({})),
|
|
4032
5090
|
phaseRef: Type.Optional(Type.String({})),
|
|
@@ -4057,7 +5115,7 @@ export default function planPiExtension(pi) {
|
|
|
4057
5115
|
for (const task of phase.tasks) {
|
|
4058
5116
|
if (params.status && task.status !== params.status)
|
|
4059
5117
|
continue;
|
|
4060
|
-
out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
|
|
5118
|
+
out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
|
|
4061
5119
|
}
|
|
4062
5120
|
}
|
|
4063
5121
|
return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
|
|
@@ -4066,21 +5124,20 @@ export default function planPiExtension(pi) {
|
|
|
4066
5124
|
pi.registerTool({
|
|
4067
5125
|
name: "task_get",
|
|
4068
5126
|
label: "Task Get",
|
|
4069
|
-
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.",
|
|
4070
5128
|
parameters: Type.Object({
|
|
4071
5129
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4072
5130
|
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
4073
5131
|
}),
|
|
4074
5132
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4075
5133
|
const st = await requirePlan(ctx);
|
|
5134
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4076
5135
|
if (!st)
|
|
4077
5136
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4078
5137
|
const features = (await st.loadFeatures()).features;
|
|
4079
5138
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4080
5139
|
if (!found)
|
|
4081
5140
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4082
|
-
if (params.full)
|
|
4083
|
-
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
4084
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})`;
|
|
4085
5142
|
if (!params.full)
|
|
4086
5143
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
@@ -4096,6 +5153,8 @@ export default function planPiExtension(pi) {
|
|
|
4096
5153
|
const sections = [summary];
|
|
4097
5154
|
if (found.task.description?.trim())
|
|
4098
5155
|
sections.push(found.task.description.trim());
|
|
5156
|
+
if (found.task.descriptionRef)
|
|
5157
|
+
sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
|
|
4099
5158
|
if (snapshot || pendingDeviation) {
|
|
4100
5159
|
sections.push([
|
|
4101
5160
|
pendingDeviation
|
|
@@ -4109,7 +5168,20 @@ export default function planPiExtension(pi) {
|
|
|
4109
5168
|
}
|
|
4110
5169
|
if (log)
|
|
4111
5170
|
sections.push(`Status log:\n${log}`);
|
|
4112
|
-
|
|
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;
|
|
4113
5185
|
},
|
|
4114
5186
|
});
|
|
4115
5187
|
pi.registerTool({
|
|
@@ -4187,6 +5259,7 @@ export default function planPiExtension(pi) {
|
|
|
4187
5259
|
pauseHistory: [],
|
|
4188
5260
|
startedAt: "",
|
|
4189
5261
|
completedAt: "",
|
|
5262
|
+
activeOwnerSession: "",
|
|
4190
5263
|
createdAt: now, updatedAt: now,
|
|
4191
5264
|
};
|
|
4192
5265
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
@@ -4202,6 +5275,58 @@ export default function planPiExtension(pi) {
|
|
|
4202
5275
|
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
4203
5276
|
},
|
|
4204
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
|
+
});
|
|
4205
5330
|
pi.registerTool({
|
|
4206
5331
|
name: "task_update",
|
|
4207
5332
|
label: "Task Update",
|
|
@@ -4211,6 +5336,7 @@ export default function planPiExtension(pi) {
|
|
|
4211
5336
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
4212
5337
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
4213
5338
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
5339
|
+
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
|
|
4214
5340
|
notes: Type.Optional(Type.String({ description: "New implementation notes" })),
|
|
4215
5341
|
motivation: Type.Optional(Type.String({ description: "Motivation for status change. REQUIRED when changing to blocked, canceled, rejected, deferred, waiting, or back to planned from another status." })),
|
|
4216
5342
|
decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
|
|
@@ -4221,9 +5347,10 @@ export default function planPiExtension(pi) {
|
|
|
4221
5347
|
rationale: Type.String(),
|
|
4222
5348
|
implementationNotes: Type.String(),
|
|
4223
5349
|
acceptedAt: Type.String(),
|
|
4224
|
-
}), { description: "
|
|
5350
|
+
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
4225
5351
|
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
4226
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." })),
|
|
4227
5354
|
}),
|
|
4228
5355
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4229
5356
|
const st = await requirePlan(ctx);
|
|
@@ -4252,6 +5379,13 @@ export default function planPiExtension(pi) {
|
|
|
4252
5379
|
details: {},
|
|
4253
5380
|
};
|
|
4254
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
|
+
}
|
|
4255
5389
|
if (params.status !== undefined &&
|
|
4256
5390
|
params.status !== found.task.status &&
|
|
4257
5391
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4262,6 +5396,29 @@ export default function planPiExtension(pi) {
|
|
|
4262
5396
|
};
|
|
4263
5397
|
}
|
|
4264
5398
|
}
|
|
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"];
|
|
5405
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
5406
|
+
if (receivedFields.length === 0) {
|
|
5407
|
+
return mutationNoFieldsFailure({
|
|
5408
|
+
entity: "task",
|
|
5409
|
+
ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5410
|
+
operation: "update",
|
|
5411
|
+
mutableFields,
|
|
5412
|
+
retryCommand: `task_update ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5413
|
+
readBackCommand: `task_get ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)} with full=true`,
|
|
5414
|
+
descriptionFallback: {
|
|
5415
|
+
entityId: found.task.id,
|
|
5416
|
+
inputField: "description",
|
|
5417
|
+
storedField: "task description",
|
|
5418
|
+
descriptionRefField: "descriptionRef",
|
|
5419
|
+
},
|
|
5420
|
+
});
|
|
5421
|
+
}
|
|
4265
5422
|
try {
|
|
4266
5423
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
4267
5424
|
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
@@ -4272,13 +5429,18 @@ export default function planPiExtension(pi) {
|
|
|
4272
5429
|
if (params.priority !== undefined)
|
|
4273
5430
|
task.priority = params.priority;
|
|
4274
5431
|
if (params.description !== undefined)
|
|
4275
|
-
task.description = params.description;
|
|
5432
|
+
task.description = params.description.trim();
|
|
5433
|
+
if (params.descriptionRef !== undefined) {
|
|
5434
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
5435
|
+
if (descriptionRef)
|
|
5436
|
+
task.descriptionRef = descriptionRef;
|
|
5437
|
+
else
|
|
5438
|
+
delete task.descriptionRef;
|
|
5439
|
+
}
|
|
4276
5440
|
if (params.notes !== undefined)
|
|
4277
|
-
task.notes = params.notes;
|
|
5441
|
+
task.notes = params.notes.trim();
|
|
4278
5442
|
if (params.decisions !== undefined)
|
|
4279
|
-
task.decisions = params.decisions;
|
|
4280
|
-
if (params.acceptedDecisions !== undefined)
|
|
4281
|
-
task.acceptedDecisions = params.acceptedDecisions;
|
|
5443
|
+
task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
4282
5444
|
if (params.checklist !== undefined) {
|
|
4283
5445
|
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
4284
5446
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
@@ -4286,6 +5448,9 @@ export default function planPiExtension(pi) {
|
|
|
4286
5448
|
checked: false,
|
|
4287
5449
|
}));
|
|
4288
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
|
+
}
|
|
4289
5454
|
if (params.status !== undefined) {
|
|
4290
5455
|
if (params.status !== task.status) {
|
|
4291
5456
|
const entry = {
|
|
@@ -4314,7 +5479,17 @@ export default function planPiExtension(pi) {
|
|
|
4314
5479
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4315
5480
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4316
5481
|
await st.writeGenerated();
|
|
4317
|
-
|
|
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 } : {}) } };
|
|
4318
5493
|
},
|
|
4319
5494
|
});
|
|
4320
5495
|
pi.registerTool({
|
|
@@ -4346,21 +5521,38 @@ export default function planPiExtension(pi) {
|
|
|
4346
5521
|
pi.registerTool({
|
|
4347
5522
|
name: "task_recommend",
|
|
4348
5523
|
label: "Task Recommend",
|
|
4349
|
-
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.",
|
|
4350
5525
|
parameters: Type.Object({}),
|
|
4351
5526
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4352
5527
|
const st = await requirePlan(ctx);
|
|
4353
5528
|
if (!st)
|
|
4354
5529
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4355
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
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;
|
|
4360
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")}` : "";
|
|
4361
5553
|
return {
|
|
4362
|
-
content: [{ type: "text", text:
|
|
4363
|
-
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 },
|
|
4364
5556
|
};
|
|
4365
5557
|
},
|
|
4366
5558
|
});
|
|
@@ -4463,7 +5655,7 @@ export default function planPiExtension(pi) {
|
|
|
4463
5655
|
pi.registerTool({
|
|
4464
5656
|
name: "task_switch",
|
|
4465
5657
|
label: "Task Switch",
|
|
4466
|
-
description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
|
|
5658
|
+
description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
|
|
4467
5659
|
parameters: Type.Object({
|
|
4468
5660
|
from_task: Type.String({ description: "Currently in-progress task ref" }),
|
|
4469
5661
|
to_task: Type.String({ description: "Temporary task ref to start" }),
|
|
@@ -4475,6 +5667,7 @@ export default function planPiExtension(pi) {
|
|
|
4475
5667
|
}),
|
|
4476
5668
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4477
5669
|
const st = await requirePlan(ctx);
|
|
5670
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4478
5671
|
if (!st)
|
|
4479
5672
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4480
5673
|
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
@@ -4490,6 +5683,9 @@ export default function planPiExtension(pi) {
|
|
|
4490
5683
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4491
5684
|
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4492
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
|
+
}
|
|
4493
5689
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
4494
5690
|
const linkedRequirements = [
|
|
4495
5691
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -4508,16 +5704,26 @@ export default function planPiExtension(pi) {
|
|
|
4508
5704
|
requirementIds: linkedRequirementIds,
|
|
4509
5705
|
};
|
|
4510
5706
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4511
|
-
const
|
|
5707
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5708
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
5709
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5710
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
4512
5711
|
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
4513
5712
|
const targetTaskRef = `${targetPhaseRef}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4514
5713
|
const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
|
|
5714
|
+
if (!projectGuidelinesReady) {
|
|
5715
|
+
const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
|
|
5716
|
+
return {
|
|
5717
|
+
content: [{ type: "text", text: `Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.` }],
|
|
5718
|
+
details: { projectGuidelinesReadState, contextEligibility, nextActions },
|
|
5719
|
+
};
|
|
5720
|
+
}
|
|
4515
5721
|
if (!contextEligibility.eligible) {
|
|
4516
|
-
const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
|
|
5722
|
+
const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
|
|
4517
5723
|
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
|
|
4518
5724
|
}
|
|
4519
5725
|
if (!requirementsReady) {
|
|
4520
|
-
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}`] } };
|
|
4521
5727
|
}
|
|
4522
5728
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
4523
5729
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -4531,7 +5737,7 @@ export default function planPiExtension(pi) {
|
|
|
4531
5737
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
4532
5738
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
4533
5739
|
};
|
|
4534
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
5740
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
4535
5741
|
const record = {
|
|
4536
5742
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
4537
5743
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
@@ -4542,75 +5748,78 @@ export default function planPiExtension(pi) {
|
|
|
4542
5748
|
const sourceWasActive = source.task.status === "in-progress";
|
|
4543
5749
|
let sourceCheckpointed = false;
|
|
4544
5750
|
let targetStarted = false;
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
4552
|
-
if (!task)
|
|
4553
|
-
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
4554
|
-
task.pauseSnapshot = snapshot;
|
|
4555
|
-
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
4556
|
-
task.updatedAt = timestamp;
|
|
4557
|
-
return phase;
|
|
4558
|
-
});
|
|
4559
|
-
}
|
|
4560
|
-
sourceCheckpointed = true;
|
|
4561
|
-
if (target.task.pauseSnapshot) {
|
|
4562
|
-
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
4563
|
-
}
|
|
4564
|
-
else {
|
|
4565
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
4566
|
-
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
4567
|
-
if (!task)
|
|
4568
|
-
throw new Error(`Temporary task disappeared: ${params.to_task}`);
|
|
4569
|
-
const previousStatus = task.status;
|
|
4570
|
-
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
4571
|
-
task.statusLog = [...task.statusLog, {
|
|
4572
|
-
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
4573
|
-
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
4574
|
-
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
4575
|
-
}];
|
|
4576
|
-
task.updatedAt = timestamp;
|
|
4577
|
-
phase.updatedAt = timestamp;
|
|
4578
|
-
return phase;
|
|
4579
|
-
});
|
|
4580
|
-
}
|
|
4581
|
-
targetStarted = true;
|
|
4582
|
-
await st.addWorkDeviation(record);
|
|
4583
|
-
if (target.task.pauseSnapshot) {
|
|
4584
|
-
const resumedDeviation = project.workDeviations
|
|
4585
|
-
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
4586
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
4587
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4588
|
-
if (resumedDeviation)
|
|
4589
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
4590
|
-
}
|
|
4591
|
-
}
|
|
4592
|
-
catch (error) {
|
|
4593
|
-
if (targetStarted) {
|
|
4594
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
4595
|
-
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
4596
|
-
return phase;
|
|
4597
|
-
}).catch(() => { });
|
|
4598
|
-
}
|
|
4599
|
-
if (sourceCheckpointed) {
|
|
4600
|
-
if (sourceWasActive)
|
|
4601
|
-
await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
|
|
4602
|
-
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 {
|
|
4603
5757
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
4604
|
-
|
|
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);
|
|
4605
5804
|
return phase;
|
|
4606
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;
|
|
4607
5817
|
}
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
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
|
+
});
|
|
4614
5823
|
invalidateReads(plannerSessionId);
|
|
4615
5824
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
4616
5825
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
@@ -4625,156 +5834,192 @@ export default function planPiExtension(pi) {
|
|
|
4625
5834
|
};
|
|
4626
5835
|
},
|
|
4627
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
|
+
});
|
|
4628
5862
|
pi.registerTool({
|
|
4629
5863
|
name: "task_start",
|
|
4630
5864
|
label: "Task Start",
|
|
4631
|
-
description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and
|
|
5865
|
+
description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. A denied start is an isError result with started=false; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
|
|
4632
5866
|
parameters: Type.Object({
|
|
4633
5867
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4634
5868
|
}),
|
|
4635
5869
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4636
5870
|
const st = await requirePlan(ctx);
|
|
5871
|
+
const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
4637
5872
|
if (!st)
|
|
4638
5873
|
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
phaseId: found.phase.id,
|
|
4659
|
-
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4660
|
-
task,
|
|
4661
|
-
phase: found.phase,
|
|
4662
|
-
...(feature ? { feature } : {}),
|
|
4663
|
-
requirements: linkedRequirements,
|
|
4664
|
-
requirementIds: linkedRequirementIds,
|
|
4665
|
-
};
|
|
4666
|
-
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4667
|
-
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
4668
|
-
if (!contextEligibility.eligible) {
|
|
4669
|
-
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
|
|
4670
|
-
}
|
|
4671
|
-
if (!requirementsReady) {
|
|
4672
|
-
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 }));
|
|
4673
|
-
}
|
|
4674
|
-
if (!hasValidSessionAttestation(contextInput)) {
|
|
4675
|
-
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 = {
|
|
4676
5893
|
sessionId: plannerSessionId,
|
|
4677
|
-
phaseId: found.phase.id,
|
|
4678
5894
|
taskId: task.id,
|
|
5895
|
+
phaseId: found.phase.id,
|
|
4679
5896
|
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5897
|
+
task,
|
|
5898
|
+
phase: found.phase,
|
|
5899
|
+
...(feature ? { feature } : {}),
|
|
5900
|
+
requirements: linkedRequirements,
|
|
4680
5901
|
requirementIds: linkedRequirementIds,
|
|
4681
|
-
}
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
const
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
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;
|
|
4743
5998
|
return phase;
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
4769
|
-
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
4770
|
-
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 }));
|
|
4771
|
-
}
|
|
4772
|
-
const outcome = taskStartSucceeded(persistedTask.id);
|
|
4773
|
-
return {
|
|
4774
|
-
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
4775
|
-
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
4776
|
-
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4777
|
-
};
|
|
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
|
+
});
|
|
4778
6023
|
},
|
|
4779
6024
|
});
|
|
4780
6025
|
pi.registerTool({
|
|
@@ -5036,10 +6281,24 @@ export default function planPiExtension(pi) {
|
|
|
5036
6281
|
const st = await requirePlan(ctx);
|
|
5037
6282
|
if (!st)
|
|
5038
6283
|
return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
|
|
6284
|
+
let preparation;
|
|
6285
|
+
try {
|
|
6286
|
+
preparation = await st.preparePlannerSession();
|
|
6287
|
+
}
|
|
6288
|
+
catch (error) {
|
|
6289
|
+
return { content: [{ type: "text", text: `Planner load aborted: ${error instanceof Error ? error.message : String(error)}` }], details: { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", enabled: false, running: false } };
|
|
6290
|
+
}
|
|
5039
6291
|
enablePlannerSession();
|
|
5040
6292
|
if (!server) {
|
|
5041
6293
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
5042
6294
|
}
|
|
6295
|
+
const project = await st.loadProject();
|
|
6296
|
+
if (project.projectGuidelines.content.trim()) {
|
|
6297
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
6298
|
+
}
|
|
6299
|
+
const plannerSkill = await st.syncPlannerSkill();
|
|
6300
|
+
await st.syncGrillMeSkill();
|
|
6301
|
+
contextBlockDirty = true;
|
|
5043
6302
|
const srv = server;
|
|
5044
6303
|
let recap = "";
|
|
5045
6304
|
try {
|
|
@@ -5048,7 +6307,9 @@ export default function planPiExtension(pi) {
|
|
|
5048
6307
|
catch (e) {
|
|
5049
6308
|
recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
5050
6309
|
}
|
|
5051
|
-
|
|
6310
|
+
if (preparation.changed)
|
|
6311
|
+
recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
|
|
6312
|
+
return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort, preparation: preparation.legacyProjectContext, plannerSkill: { status: plannerSkill.status, customized: plannerSkill.customized, message: plannerSkill.message } } };
|
|
5052
6313
|
},
|
|
5053
6314
|
});
|
|
5054
6315
|
pi.registerTool({
|
|
@@ -5109,6 +6370,7 @@ export default function planPiExtension(pi) {
|
|
|
5109
6370
|
const recentActivity = activity.entries.slice(-3).reverse();
|
|
5110
6371
|
const handoffs = await st.listHandoffs();
|
|
5111
6372
|
const extensionRules = await st.extensionRules();
|
|
6373
|
+
const plannerSkill = await st.syncPlannerSkill();
|
|
5112
6374
|
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
5113
6375
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
5114
6376
|
const orderedPhases = [
|
|
@@ -5180,12 +6442,15 @@ export default function planPiExtension(pi) {
|
|
|
5180
6442
|
const openQuestions = plan.phases
|
|
5181
6443
|
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
5182
6444
|
.slice(0, 8);
|
|
5183
|
-
const
|
|
5184
|
-
|
|
5185
|
-
...plan.features.features.
|
|
5186
|
-
...plan.phases.
|
|
5187
|
-
...plan.phases.flatMap((phase) => phase.tasks.
|
|
5188
|
-
|
|
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
|
+
]);
|
|
5189
6454
|
const webServer = server;
|
|
5190
6455
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
5191
6456
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -5223,11 +6488,11 @@ export default function planPiExtension(pi) {
|
|
|
5223
6488
|
"",
|
|
5224
6489
|
"Operational rules:",
|
|
5225
6490
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
5226
|
-
"- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale
|
|
6491
|
+
"- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. A denial is an error with started=false; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
|
|
5227
6492
|
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
5228
6493
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
5229
6494
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
5230
|
-
"- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs.
|
|
6495
|
+
"- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Compact feature_list / phase_list / task_list surfaces expose priority markers; when browsing manually, follow the lowest visible priority first among ready siblings. Prefer shortId or F00x/P00x/T00x refs. Read one entity via *_get(full=true).",
|
|
5231
6496
|
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
5232
6497
|
...(extensionRules.length > 0
|
|
5233
6498
|
? [
|
|
@@ -5237,12 +6502,20 @@ export default function planPiExtension(pi) {
|
|
|
5237
6502
|
]
|
|
5238
6503
|
: []),
|
|
5239
6504
|
"",
|
|
6505
|
+
"Project-local planner usage skill (agent-only; keep in working context and do not quote in user recaps):",
|
|
6506
|
+
plannerSkill.customized ? `Skill drift: ${plannerSkill.message}` : "",
|
|
6507
|
+
plannerSkill.content,
|
|
6508
|
+
"End project-local planner usage skill.",
|
|
6509
|
+
"",
|
|
5240
6510
|
`Goal: ${project.goal || "(not set)"}`,
|
|
5241
6511
|
project.description ? `Description: ${project.description}` : "",
|
|
5242
6512
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
5243
6513
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
5244
6514
|
`Ambient: ${ambientSummary}`,
|
|
5245
6515
|
"",
|
|
6516
|
+
"Project Guidelines (mandatory when present):",
|
|
6517
|
+
project.projectGuidelines.content.trim() || "- (none)",
|
|
6518
|
+
"",
|
|
5246
6519
|
"Global rules:",
|
|
5247
6520
|
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
5248
6521
|
"",
|
|
@@ -5250,8 +6523,7 @@ export default function planPiExtension(pi) {
|
|
|
5250
6523
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
5251
6524
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
5252
6525
|
"",
|
|
5253
|
-
|
|
5254
|
-
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
6526
|
+
acceptedDecisionContext.content,
|
|
5255
6527
|
openQuestions.length ? "" : "",
|
|
5256
6528
|
openQuestions.length ? "Open questions:" : "",
|
|
5257
6529
|
...openQuestions.map((question) => `- ${question}`),
|