@agent-plan/pi-adapter 0.2.29 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +512 -614
- package/package.json +3 -3
- package/web-ui-dist/assets/index-CmsfDpZ0.js +334 -0
- package/web-ui-dist/assets/index-DKmMhG5Y.css +1 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-C5sLUPQ_.js +0 -329
- package/web-ui-dist/assets/index-DHgouqOR.css +0 -1
package/dist/index.js
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { paginatedSelect, paginatedNotify } from "./ui/paginate.js";
|
|
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,
|
|
14
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
13
|
+
import { ExportService, PlanStore, PlanStoreError, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, classifyGuardedTool, loadNoTaskGuardState, noTaskWarning, addChecklistItem, removeChecklistItem, toggleChecklistItem, replaceChecklist, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, taskStartGateState, taskStartGateStateLine, 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, handoffContentHash, HandoffContractError, buildHandoffShowReply, buildHandoffPrepareReply, buildProjectContextLoadReply, buildPlannerLoadReply, buildScopedDecisionContext, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS, projectContextStaleAdvisory, ACCEPTED_DECISION_OWNERSHIP_RULE, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, buildDecisionRecordRedirectReply, buildMutationReply, longTextFieldLimitNotice, buildRecommendationReply, buildTaskOrderContext, taskOrderContextLine, taskCreatedPriorityFragment, findHigherPriorityOpenPhase, higherPriorityOpenPhaseAdvisory, listOpenPhaseWork, openPhaseWorkLines, boundedOpenPhaseWork, deleteFeatureCascade, evaluateTaskCompletionGate, taskCompletionChecklistRefusal, taskCompletionForceMotivationRequired, taskCompletionOverrideNote, taskCompletionMismatchLine, boundedPage, nowISO, resolveFeatureRefStrict, resolveAcceptedDecisionTarget, applyTaskLifecycleDates, handoffContractFailureReply, derivedStatusReadOnlyReply, acceptedDecisionMutationFailureReply, legacyContextMigrationSummary, statusIcon } from "@agent-plan/core";
|
|
14
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
15
|
+
/** Stated on every long-prose input so a caller learns the limit before the
|
|
16
|
+
* call rather than from the rejection. The number lives in the core
|
|
17
|
+
* (PLANNER_LONG_TEXT_FIELD_MAX_CHARS) so both adapters state the same one. */
|
|
18
|
+
const LONG_TEXT_LIMIT_NOTICE = longTextFieldLimitNotice();
|
|
15
19
|
import { join, dirname } from "node:path";
|
|
16
20
|
import { homedir } from "node:os";
|
|
17
21
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
@@ -27,6 +31,23 @@ const PI_ADAPTER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-pla
|
|
|
27
31
|
const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
|
|
28
32
|
const SERVER_PACKAGE = resolvedPackageVersion("@agent-plan/server", import.meta.url);
|
|
29
33
|
let capturedPi = null;
|
|
34
|
+
/**
|
|
35
|
+
* Hand a message to the agent, and report whether the bridge was there.
|
|
36
|
+
*
|
|
37
|
+
* `ExtensionAPI.sendUserMessage` returns `void`, not a promise: it queues
|
|
38
|
+
* the message and returns immediately. Four call sites awaited it, which
|
|
39
|
+
* reads as "the message has been delivered, now continue" and is not true —
|
|
40
|
+
* TypeScript flagged every one of them. They also each rewrote the same
|
|
41
|
+
* `capturedPi` null check. Both live here now, so no caller re-derives
|
|
42
|
+
* either, and the missing-bridge case stays each caller's own decision:
|
|
43
|
+
* some notify the user, some carry on silently.
|
|
44
|
+
*/
|
|
45
|
+
function sendToAgent(message) {
|
|
46
|
+
if (!capturedPi)
|
|
47
|
+
return false;
|
|
48
|
+
capturedPi.sendUserMessage(message);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
30
51
|
let store = null;
|
|
31
52
|
let server = null;
|
|
32
53
|
let lastKnownWebPort = null;
|
|
@@ -34,7 +55,6 @@ let plannerSessionId = "pi:uninitialized";
|
|
|
34
55
|
// True while the agent is mutating .planner/ files. The web server returns 503-busy
|
|
35
56
|
// during this window so the UI doesn't render inconsistent data.
|
|
36
57
|
let planBusy = false;
|
|
37
|
-
function setPlanBusy(value) { planBusy = value; }
|
|
38
58
|
function withPlanBusy(fn) {
|
|
39
59
|
return Promise.resolve()
|
|
40
60
|
.then(() => { planBusy = true; })
|
|
@@ -55,38 +75,14 @@ function taskStartFailure(outcome, details = {}) {
|
|
|
55
75
|
details: { ...outcome, ...details },
|
|
56
76
|
};
|
|
57
77
|
}
|
|
58
|
-
function
|
|
59
|
-
const childKind = entity === "feature" ? "phases" : "tasks";
|
|
78
|
+
function piDerivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
60
79
|
const retryAction = entity === "feature"
|
|
61
80
|
? `Retry feature_update ${ref} without the status field if you need to update metadata.`
|
|
62
81
|
: `Retry phase_update ${ref} without the status field if you need to update metadata.`;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
retryAction,
|
|
66
|
-
];
|
|
67
|
-
return {
|
|
68
|
-
isError: true,
|
|
69
|
-
content: [{ type: "text", text: [
|
|
70
|
-
`❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
|
|
71
|
-
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
|
|
72
|
-
`Attempted status: ${attemptedStatus}`,
|
|
73
|
-
`Effective derived status: ${effectiveStatus}`,
|
|
74
|
-
"No planner data was changed.",
|
|
75
|
-
"Next required actions:",
|
|
76
|
-
...nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
77
|
-
].join("\n") }],
|
|
78
|
-
details: {
|
|
79
|
-
updated: false,
|
|
80
|
-
errorCode: "DERIVED_STATUS_READ_ONLY",
|
|
81
|
-
entity,
|
|
82
|
-
ref,
|
|
83
|
-
attemptedStatus,
|
|
84
|
-
effectiveStatus,
|
|
85
|
-
nextActions,
|
|
86
|
-
},
|
|
87
|
-
};
|
|
82
|
+
const reply = derivedStatusReadOnlyReply({ entity, ref, attemptedStatus, effectiveStatus, retryAction });
|
|
83
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
88
84
|
}
|
|
89
|
-
function
|
|
85
|
+
function piMutationNoFieldsFailure(input) {
|
|
90
86
|
const outcome = noMutableFieldsReceived(input);
|
|
91
87
|
return {
|
|
92
88
|
isError: true,
|
|
@@ -94,52 +90,13 @@ function mutationNoFieldsFailure(input) {
|
|
|
94
90
|
details: outcome,
|
|
95
91
|
};
|
|
96
92
|
}
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
};
|
|
93
|
+
function piAcceptedDecisionMutationFailure(error, outcome) {
|
|
94
|
+
const reply = acceptedDecisionMutationFailureReply(error, outcome);
|
|
95
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
131
96
|
}
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
};
|
|
97
|
+
function piHandoffContractFailure(error, action = "refresh") {
|
|
98
|
+
const reply = handoffContractFailureReply(error, action);
|
|
99
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
143
100
|
}
|
|
144
101
|
function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
|
|
145
102
|
const actions = projectGuidelinesReadRequired ? ["project_guidelines_show"] : [];
|
|
@@ -187,11 +144,9 @@ setWriteNotifyHook(() => {
|
|
|
187
144
|
}
|
|
188
145
|
catch { }
|
|
189
146
|
});
|
|
190
|
-
let previousContextPercent = 0;
|
|
191
|
-
let autoHandoffTriggered = false;
|
|
192
147
|
let plannerSessionEnabled = false;
|
|
193
148
|
// In-process guard bypass (per session, NOT persisted/shared). The edit/write
|
|
194
|
-
//
|
|
149
|
+
// The no-task guard only ever warns the agent; it never blocks. When a
|
|
195
150
|
// bypass is active the warning is silenced for the window. Lives in memory only:
|
|
196
151
|
// not on disk, not in git, not shared across agents/sessions on the same folder.
|
|
197
152
|
let guardBypassUntil = "";
|
|
@@ -216,11 +171,9 @@ function isGuardBypassed() {
|
|
|
216
171
|
let startupResumePromptPending = false;
|
|
217
172
|
let startupResumeSummaryPending = false;
|
|
218
173
|
let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
|
|
219
|
-
let startupResumeSummaryText = "";
|
|
220
174
|
let startupResumeSummaryTimer = null;
|
|
221
175
|
let contextBlockCache = ""; // cached before_agent_start context; rebuilt only when plan changes
|
|
222
176
|
let contextBlockDirty = true; // build on first turn; invalidated by write notify hook
|
|
223
|
-
let editedThisTurn = false; // tracks edit/write activity for the task_complete reminder
|
|
224
177
|
let taskCompleteReminderSaidThisTurn = false;
|
|
225
178
|
const healedStatusRoots = new Set();
|
|
226
179
|
const PLANNER_COMMAND_COMPLETIONS = [
|
|
@@ -257,6 +210,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
257
210
|
{ value: "task delete", label: "task delete", description: "Delete a task" },
|
|
258
211
|
{ value: "task start", label: "task start", description: "Mark a task in-progress" },
|
|
259
212
|
{ value: "task complete", label: "task complete", description: "Mark a task done" },
|
|
213
|
+
{ value: "task recommend", label: "task recommend", description: "Show the next-work recommendation: active task if present, otherwise the priority pick" },
|
|
260
214
|
{ value: "task checklist-add", label: "task checklist-add", description: "Add a checklist step: /planner task checklist-add <T00x> <title>" },
|
|
261
215
|
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
262
216
|
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
@@ -282,15 +236,9 @@ function hasPlannerCommandArgument(prefix) {
|
|
|
282
236
|
return PLANNER_COMMAND_COMPLETIONS.some((item) => normalized.startsWith(`${item.value.toLowerCase()} `));
|
|
283
237
|
}
|
|
284
238
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
285
|
-
function nowISO() {
|
|
286
|
-
return new Date().toISOString();
|
|
287
|
-
}
|
|
288
239
|
function manifestPathFor(root) {
|
|
289
240
|
return join(root, "manifest.json");
|
|
290
241
|
}
|
|
291
|
-
function createRequirementId() {
|
|
292
|
-
return crypto.randomUUID();
|
|
293
|
-
}
|
|
294
242
|
function planRootForCwd(cwd) {
|
|
295
243
|
return join(cwd, PLAN_DIR_NAME);
|
|
296
244
|
}
|
|
@@ -316,11 +264,9 @@ function resetState() {
|
|
|
316
264
|
plannerSessionEnabled = false;
|
|
317
265
|
startupResumePromptPending = false;
|
|
318
266
|
startupResumeSummaryPending = false;
|
|
319
|
-
startupResumeSummaryText = "";
|
|
320
267
|
plannerHeavyInitDone = false;
|
|
321
268
|
contextBlockCache = "";
|
|
322
269
|
contextBlockDirty = true;
|
|
323
|
-
editedThisTurn = false;
|
|
324
270
|
taskCompleteReminderSaidThisTurn = false;
|
|
325
271
|
}
|
|
326
272
|
async function maybeHealStatuses(st, ctx) {
|
|
@@ -365,68 +311,6 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
|
365
311
|
}
|
|
366
312
|
return project;
|
|
367
313
|
}
|
|
368
|
-
async function getPlannerExecutionGuard(st) {
|
|
369
|
-
const [workspace, resume] = await Promise.all([st.loadAll(), st.loadResume()]);
|
|
370
|
-
const allTasks = workspace.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
371
|
-
const inProgress = allTasks.filter(({ task }) => task.status === "in-progress");
|
|
372
|
-
const totalTasks = allTasks.length;
|
|
373
|
-
const focusFromResume = resume?.inProgressTaskIds?.[0]
|
|
374
|
-
? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
|
|
375
|
-
: undefined;
|
|
376
|
-
const focusFromCurrentPhase = resume?.currentPhaseId
|
|
377
|
-
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
|
|
378
|
-
: undefined;
|
|
379
|
-
const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
|
|
380
|
-
?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
|
|
381
|
-
?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
|
|
382
|
-
const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
|
|
383
|
-
return {
|
|
384
|
-
totalTasks,
|
|
385
|
-
inProgressTaskIds: inProgress.map(({ task }) => task.id),
|
|
386
|
-
focusTaskId: focus?.task.id ?? "",
|
|
387
|
-
focusTaskTitle: focus?.task.title ?? "",
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
function formatSequence(value) {
|
|
391
|
-
return String(value && value > 0 ? value : 0).padStart(3, "0");
|
|
392
|
-
}
|
|
393
|
-
function featureLabel(feature) {
|
|
394
|
-
return `F${formatSequence(feature.number)} — ${feature.name}`;
|
|
395
|
-
}
|
|
396
|
-
function phaseLabel(phase) {
|
|
397
|
-
return `P${formatSequence(phase.number)} — ${phase.title}`;
|
|
398
|
-
}
|
|
399
|
-
function taskLabel(task) {
|
|
400
|
-
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
401
|
-
}
|
|
402
|
-
function resolveFeatureRefStrict(features, ref) {
|
|
403
|
-
const raw = ref.trim();
|
|
404
|
-
if (!raw)
|
|
405
|
-
return { ok: false, error: "Feature ref is required." };
|
|
406
|
-
const normalized = raw.toLowerCase();
|
|
407
|
-
const byNumber = normalized.match(/^f(\d+)$/)
|
|
408
|
-
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
409
|
-
: undefined;
|
|
410
|
-
if (byNumber)
|
|
411
|
-
return { ok: true, feature: byNumber };
|
|
412
|
-
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
413
|
-
if (byShortId)
|
|
414
|
-
return { ok: true, feature: byShortId };
|
|
415
|
-
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
416
|
-
if (byId)
|
|
417
|
-
return { ok: true, feature: byId };
|
|
418
|
-
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
419
|
-
if (exactName.length === 1)
|
|
420
|
-
return { ok: true, feature: exactName[0] };
|
|
421
|
-
if (exactName.length > 1)
|
|
422
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
423
|
-
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
424
|
-
if (partialName.length === 1)
|
|
425
|
-
return { ok: true, feature: partialName[0] };
|
|
426
|
-
if (partialName.length > 1)
|
|
427
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
428
|
-
return { ok: false, error: `Feature not found: ${raw}` };
|
|
429
|
-
}
|
|
430
314
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
431
315
|
const [plan, resume, activity] = await Promise.all([
|
|
432
316
|
st.loadAll(),
|
|
@@ -684,59 +568,9 @@ function readUiConfig(ctx) {
|
|
|
684
568
|
},
|
|
685
569
|
};
|
|
686
570
|
}
|
|
687
|
-
function statusIcon(status) {
|
|
688
|
-
const icons = {
|
|
689
|
-
draft: "📄", discovery: "🔍", planned: "📋",
|
|
690
|
-
"in-progress": "🚧", done: "✅", blocked: "🚫", canceled: "❌",
|
|
691
|
-
rejected: "❌", deferred: "⏳", waiting: "⏱️",
|
|
692
|
-
};
|
|
693
|
-
return icons[status] ?? "❓";
|
|
694
|
-
}
|
|
695
571
|
function pad(n) {
|
|
696
572
|
return String(n).padStart(3, "0");
|
|
697
573
|
}
|
|
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
|
-
}
|
|
725
|
-
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
726
|
-
const previousStatus = task.status;
|
|
727
|
-
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
728
|
-
task.startedAt = now;
|
|
729
|
-
}
|
|
730
|
-
if (nextStatus === "done") {
|
|
731
|
-
if (!task.startedAt)
|
|
732
|
-
task.startedAt = now;
|
|
733
|
-
task.completedAt = now;
|
|
734
|
-
}
|
|
735
|
-
else if (previousStatus === "done") {
|
|
736
|
-
task.completedAt = "";
|
|
737
|
-
}
|
|
738
|
-
task.status = nextStatus;
|
|
739
|
-
}
|
|
740
574
|
async function isPortAvailable(port) {
|
|
741
575
|
return await new Promise((resolve) => {
|
|
742
576
|
const probe = createServer();
|
|
@@ -918,7 +752,6 @@ function disablePlannerSession() {
|
|
|
918
752
|
plannerSessionEnabled = false;
|
|
919
753
|
startupResumePromptPending = false;
|
|
920
754
|
startupResumeSummaryPending = false;
|
|
921
|
-
startupResumeSummaryText = "";
|
|
922
755
|
if (startupResumeSummaryTimer)
|
|
923
756
|
clearTimeout(startupResumeSummaryTimer);
|
|
924
757
|
startupResumeSummaryTimer = null;
|
|
@@ -978,22 +811,11 @@ export default function planPiExtension(pi) {
|
|
|
978
811
|
},
|
|
979
812
|
}));
|
|
980
813
|
resetState();
|
|
981
|
-
autoHandoffTriggered = false;
|
|
982
|
-
previousContextPercent = 0;
|
|
983
814
|
const st = ensureStore(ctx);
|
|
984
815
|
st.enableAutoSync(true);
|
|
985
|
-
// Read any persisted web port (for reuse), but do NOT auto-start here.
|
|
986
|
-
// The two-step gating below decides whether to start the server.
|
|
987
|
-
let preferredPort;
|
|
988
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
989
|
-
if (entry.type === "custom" && entry.customType === "plan-web-state") {
|
|
990
|
-
const data = entry.data;
|
|
991
|
-
if (data?.port)
|
|
992
|
-
preferredPort = data.port;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
816
|
const exists = await st.exists().catch(() => false);
|
|
996
|
-
|
|
817
|
+
if (exists)
|
|
818
|
+
await st.loadProject().catch(() => null);
|
|
997
819
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
998
820
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
999
821
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
@@ -1046,12 +868,11 @@ export default function planPiExtension(pi) {
|
|
|
1046
868
|
const st = ensureStore(ctx);
|
|
1047
869
|
if (await st.exists().catch(() => false)) {
|
|
1048
870
|
await writeProjectHandoff(st, "before compact").catch(() => { });
|
|
1049
|
-
autoHandoffTriggered = true;
|
|
1050
871
|
}
|
|
1051
872
|
}
|
|
1052
873
|
catch { }
|
|
1053
874
|
});
|
|
1054
|
-
pi.on("session_shutdown", async (event
|
|
875
|
+
pi.on("session_shutdown", async (event) => {
|
|
1055
876
|
try {
|
|
1056
877
|
const st = store;
|
|
1057
878
|
if (st && await st.exists().catch(() => false)) {
|
|
@@ -1124,59 +945,45 @@ export default function planPiExtension(pi) {
|
|
|
1124
945
|
}
|
|
1125
946
|
}
|
|
1126
947
|
}
|
|
1127
|
-
// Guard the code-writing tools (edit/write). bash stays free so that
|
|
1128
|
-
// git pull, build, test, ls, etc. always work.
|
|
1129
|
-
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
1130
|
-
return;
|
|
1131
|
-
// Planner-internal writes (.planner/HANDOFF.md, resume.json, generated plan
|
|
1132
|
-
// files, etc.) are planner operations, NOT code edits. They never require a
|
|
1133
|
-
// task in-progress — skip the guard so the handoff can always be written.
|
|
1134
|
-
const targetPath = String(event.input?.path ?? event.input?.filePath ?? "");
|
|
1135
|
-
if (targetPath && (targetPath.includes("/.planner/") || targetPath.includes("\\.planner\\") || targetPath.startsWith(".planner/")))
|
|
1136
|
-
return;
|
|
1137
|
-
const st = loadStore(ctx);
|
|
1138
|
-
if (!(await st.exists().catch(() => false)))
|
|
1139
|
-
return;
|
|
1140
|
-
await maybeHealStatuses(st, ctx).catch(() => { });
|
|
1141
|
-
const guard = await getPlannerExecutionGuard(st).catch(() => null);
|
|
1142
|
-
if (!guard || guard.totalTasks === 0)
|
|
1143
|
-
return; // nothing to enforce yet
|
|
1144
|
-
if (guard.inProgressTaskIds.length > 0)
|
|
1145
|
-
return; // a task is open → we're good
|
|
1146
|
-
if (isGuardBypassed())
|
|
1147
|
-
return; // user authorized proceeding without a task
|
|
1148
|
-
const focusHint = guard.focusTaskId
|
|
1149
|
-
? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
|
|
1150
|
-
: `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
|
|
1151
|
-
ctx.ui.notify(`⚠️ NO ACTIVE TASK: You are editing files without an in-progress task. Remember to update the plan to maintain dashboard integrity. ${focusHint}`, "warning");
|
|
1152
|
-
return; // Allow the tool to proceed
|
|
1153
948
|
});
|
|
1154
949
|
// Reset per-turn flags at the start of each turn.
|
|
1155
950
|
pi.on("turn_start", async () => {
|
|
1156
|
-
editedThisTurn = false;
|
|
1157
951
|
taskCompleteReminderSaidThisTurn = false;
|
|
1158
952
|
});
|
|
1159
|
-
// After
|
|
1160
|
-
//
|
|
953
|
+
// After a tool runs, tell the agent (never block) when it changed project
|
|
954
|
+
// code with no task in progress; otherwise, once per turn while a task is
|
|
955
|
+
// in progress, remind it to complete the task. Notes are appended to the
|
|
956
|
+
// tool's own output, which Pi would otherwise replace.
|
|
1161
957
|
pi.on("tool_result", async (event, ctx) => {
|
|
1162
958
|
if (!plannerSessionEnabled)
|
|
1163
959
|
return;
|
|
1164
|
-
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
1165
|
-
return;
|
|
1166
|
-
editedThisTurn = true;
|
|
1167
|
-
if (taskCompleteReminderSaidThisTurn)
|
|
960
|
+
if (event.toolName !== "edit" && event.toolName !== "write" && event.toolName !== "bash")
|
|
1168
961
|
return;
|
|
1169
962
|
try {
|
|
1170
|
-
const
|
|
1171
|
-
|
|
963
|
+
const input = event.input;
|
|
964
|
+
const stringField = (value) => typeof value === "string" && value ? value : undefined;
|
|
965
|
+
const filePath = stringField(input.path ?? input.filePath);
|
|
966
|
+
const command = stringField(input.command);
|
|
967
|
+
const guardEvent = {
|
|
968
|
+
toolName: event.toolName,
|
|
969
|
+
cwd: ctx.cwd,
|
|
970
|
+
plannerRoot: resolvePlanRoot(ctx.cwd),
|
|
971
|
+
...(filePath !== undefined ? { filePath } : {}),
|
|
972
|
+
...(command !== undefined ? { command } : {}),
|
|
973
|
+
};
|
|
974
|
+
if (!classifyGuardedTool(guardEvent).guarded)
|
|
975
|
+
return;
|
|
976
|
+
const state = await loadNoTaskGuardState(loadStore(ctx));
|
|
977
|
+
const { warning } = noTaskWarning(guardEvent, { ...state, guardBypassed: state.guardBypassed || isGuardBypassed() });
|
|
978
|
+
const note = warning
|
|
979
|
+
?? (state.hasInProgressTask && !taskCompleteReminderSaidThisTurn
|
|
980
|
+
? "Reminder: when this implementation work is finished, call task_complete (or /planner task complete) so the task status moves to done and phase/feature rollups stay correct."
|
|
981
|
+
: undefined);
|
|
982
|
+
if (!note)
|
|
1172
983
|
return;
|
|
1173
|
-
|
|
1174
|
-
if (guard && guard.inProgressTaskIds.length > 0) {
|
|
984
|
+
if (!warning)
|
|
1175
985
|
taskCompleteReminderSaidThisTurn = true;
|
|
1176
|
-
|
|
1177
|
-
content: [{ type: "text", text: "Reminder: when this implementation work is finished, call task_complete (or /planner task complete) so the task status moves to done and phase/feature rollups stay correct." }],
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
986
|
+
return { content: [...event.content, { type: "text", text: note }] };
|
|
1180
987
|
}
|
|
1181
988
|
catch { }
|
|
1182
989
|
});
|
|
@@ -1453,34 +1260,6 @@ export default function planPiExtension(pi) {
|
|
|
1453
1260
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1454
1261
|
?? null;
|
|
1455
1262
|
}
|
|
1456
|
-
function resolveFeatureRef(features, ref) {
|
|
1457
|
-
const raw = ref.trim();
|
|
1458
|
-
if (!raw)
|
|
1459
|
-
return { ok: false, error: "Feature ref is required." };
|
|
1460
|
-
const normalized = raw.toLowerCase();
|
|
1461
|
-
const byNumber = normalized.match(/^f(\d+)$/)
|
|
1462
|
-
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
1463
|
-
: undefined;
|
|
1464
|
-
if (byNumber)
|
|
1465
|
-
return { ok: true, feature: byNumber };
|
|
1466
|
-
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
1467
|
-
if (byShortId)
|
|
1468
|
-
return { ok: true, feature: byShortId };
|
|
1469
|
-
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
1470
|
-
if (byId)
|
|
1471
|
-
return { ok: true, feature: byId };
|
|
1472
|
-
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
1473
|
-
if (exactName.length == 1)
|
|
1474
|
-
return { ok: true, feature: exactName[0] };
|
|
1475
|
-
if (exactName.length > 1)
|
|
1476
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
1477
|
-
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
1478
|
-
if (partialName.length == 1)
|
|
1479
|
-
return { ok: true, feature: partialName[0] };
|
|
1480
|
-
if (partialName.length > 1)
|
|
1481
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
1482
|
-
return { ok: false, error: `Feature not found: ${raw}` };
|
|
1483
|
-
}
|
|
1484
1263
|
function profile_packageManager(pkg, lockfile) {
|
|
1485
1264
|
if (pkg?.packageManager)
|
|
1486
1265
|
return pkg.packageManager;
|
|
@@ -1630,20 +1409,18 @@ export default function planPiExtension(pi) {
|
|
|
1630
1409
|
await st.saveCodebaseProfile(profile);
|
|
1631
1410
|
await st.writeGenerated();
|
|
1632
1411
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
"Once you are 100% certain and we agree the context is complete, you will then generate the exhaustive professional specification (Features, Phases, Tasks) including deep logic, code examples, and technical references as previously required.");
|
|
1646
|
-
}
|
|
1412
|
+
sendToAgent("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
|
|
1413
|
+
"Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
|
|
1414
|
+
"Your mission is to produce a professional-grade specification. " +
|
|
1415
|
+
`\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
|
|
1416
|
+
"If these preferences are already set, follow them and do NOT ask again. " +
|
|
1417
|
+
"Ask for language only if BOTH preferences are unset. If one is set and the other is unset, infer the missing one from the saved value and persist it with project_set_language_preferences without asking.\n\n" +
|
|
1418
|
+
"DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
|
|
1419
|
+
"1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
|
|
1420
|
+
"2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
|
|
1421
|
+
"3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
|
|
1422
|
+
"4. We will iterate until you have a complete, detailed picture.\n\n" +
|
|
1423
|
+
"Once you are 100% certain and we agree the context is complete, you will then generate the exhaustive professional specification (Features, Phases, Tasks) including deep logic, code examples, and technical references as previously required.");
|
|
1647
1424
|
return;
|
|
1648
1425
|
}
|
|
1649
1426
|
if (b === "language") {
|
|
@@ -1761,10 +1538,7 @@ export default function planPiExtension(pi) {
|
|
|
1761
1538
|
if (!targetType)
|
|
1762
1539
|
return;
|
|
1763
1540
|
const skill = await st.ideaDiscussionSkill();
|
|
1764
|
-
if (
|
|
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 {
|
|
1541
|
+
if (!sendToAgent(`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.`)) {
|
|
1768
1542
|
ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
|
|
1769
1543
|
}
|
|
1770
1544
|
return;
|
|
@@ -1983,26 +1757,13 @@ export default function planPiExtension(pi) {
|
|
|
1983
1757
|
ctx.ui.notify("Aborted", "warning");
|
|
1984
1758
|
return;
|
|
1985
1759
|
}
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
return
|
|
1990
|
-
});
|
|
1991
|
-
let affectedPhases = 0;
|
|
1992
|
-
if (cascade) {
|
|
1993
|
-
for (const phase of phases) {
|
|
1994
|
-
await st.deletePhase(phase.id);
|
|
1995
|
-
affectedPhases += 1;
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
else {
|
|
1999
|
-
for (const phase of phases) {
|
|
2000
|
-
await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
|
|
2001
|
-
affectedPhases += 1;
|
|
2002
|
-
}
|
|
1760
|
+
const outcome = await deleteFeatureCascade(st, feature.id, { cascade });
|
|
1761
|
+
if (!outcome.ok) {
|
|
1762
|
+
ctx.ui.notify(`Feature delete failed: ${outcome.error}`, "error");
|
|
1763
|
+
return;
|
|
2003
1764
|
}
|
|
2004
|
-
|
|
2005
|
-
ctx.ui.notify(`Feature deleted: ${
|
|
1765
|
+
const { result } = outcome;
|
|
1766
|
+
ctx.ui.notify(`Feature deleted: ${result.ref}${result.phaseCount > 0 ? (result.cascade ? `; deleted ${result.phaseCount} phase(s)` : `; unlinked ${result.phaseCount} phase(s)`) : ""}`, "info");
|
|
2006
1767
|
return;
|
|
2007
1768
|
}
|
|
2008
1769
|
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
@@ -2038,8 +1799,6 @@ export default function planPiExtension(pi) {
|
|
|
2038
1799
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
2039
1800
|
let phase;
|
|
2040
1801
|
await st.runBatch(() => withFeatureLock(feature.id, async () => {
|
|
2041
|
-
const phases = await st.loadAllPhases();
|
|
2042
|
-
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
2043
1802
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2044
1803
|
const id = createPhaseId();
|
|
2045
1804
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
@@ -2119,11 +1878,9 @@ export default function planPiExtension(pi) {
|
|
|
2119
1878
|
notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
|
|
2120
1879
|
updatedAt: nowISO(),
|
|
2121
1880
|
}));
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
"Do NOT invoke GSD. Stay in Agent Plan.");
|
|
2126
|
-
}
|
|
1881
|
+
sendToAgent(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
|
|
1882
|
+
"Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
|
|
1883
|
+
"Do NOT invoke GSD. Stay in Agent Plan.");
|
|
2127
1884
|
}
|
|
2128
1885
|
const answers = {};
|
|
2129
1886
|
const questions = [
|
|
@@ -2258,7 +2015,7 @@ export default function planPiExtension(pi) {
|
|
|
2258
2015
|
// ═══════════════════════════════════════════════════════════════
|
|
2259
2016
|
if (a === "task") {
|
|
2260
2017
|
if (!b) {
|
|
2261
|
-
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
|
|
2018
|
+
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | recommend | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
|
|
2262
2019
|
return;
|
|
2263
2020
|
}
|
|
2264
2021
|
if (b === "add") {
|
|
@@ -2304,7 +2061,7 @@ export default function planPiExtension(pi) {
|
|
|
2304
2061
|
updatedAt: nowISO(),
|
|
2305
2062
|
}));
|
|
2306
2063
|
await st.writeGenerated();
|
|
2307
|
-
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
2064
|
+
ctx.ui.notify(`Task created: ${taskId} — ${title} (${taskCreatedPriorityFragment(priority)})`, "info");
|
|
2308
2065
|
if (isYes(startDiscuss)) {
|
|
2309
2066
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
2310
2067
|
}
|
|
@@ -2333,22 +2090,24 @@ export default function planPiExtension(pi) {
|
|
|
2333
2090
|
const { phase, task } = resolved;
|
|
2334
2091
|
const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
|
|
2335
2092
|
const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
|
|
2336
|
-
const
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
}));
|
|
2093
|
+
const checklistTitles = checklistSeed?.trim() ? splitCsv(checklistSeed) : undefined;
|
|
2094
|
+
let checklistLostTicks = [];
|
|
2095
|
+
await st.updateTask(phase.id, task.id, (current) => {
|
|
2096
|
+
const next = { ...current, updatedAt: nowISO() };
|
|
2097
|
+
if (description?.trim())
|
|
2098
|
+
next.description = description.trim();
|
|
2099
|
+
if (checklistTitles) {
|
|
2100
|
+
const replacement = replaceChecklist(current.checklist ?? [], checklistTitles, task.id);
|
|
2101
|
+
next.checklist = replacement.items;
|
|
2102
|
+
checklistLostTicks = replacement.lostTicks;
|
|
2103
|
+
}
|
|
2104
|
+
return next;
|
|
2105
|
+
});
|
|
2350
2106
|
await st.writeGenerated();
|
|
2351
|
-
|
|
2107
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
2108
|
+
? ` Warning: lost tick on ${checklistLostTicks.length} item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
2109
|
+
: "";
|
|
2110
|
+
ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.${lostTicksNotice}`, checklistLostTicks.length > 0 ? "warning" : "info");
|
|
2352
2111
|
return;
|
|
2353
2112
|
}
|
|
2354
2113
|
if (b === "show") {
|
|
@@ -2480,6 +2239,35 @@ export default function planPiExtension(pi) {
|
|
|
2480
2239
|
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");
|
|
2481
2240
|
return;
|
|
2482
2241
|
}
|
|
2242
|
+
if (b === "recommend") {
|
|
2243
|
+
// "What should I work on next" is a supervisor question, not only
|
|
2244
|
+
// an agent one (see P104(F005)/T419) — the only reason it lacked an
|
|
2245
|
+
// interactive path was that the other agent-only commands (pause,
|
|
2246
|
+
// switch, deviation) set the pattern by default. Reuses the same
|
|
2247
|
+
// recommendNextWork + buildRecommendationReply pipeline as the
|
|
2248
|
+
// task_recommend tool, so a person at the command line and an agent
|
|
2249
|
+
// calling the tool see identical reasoning, just a compact few-line
|
|
2250
|
+
// notify instead of a structured payload.
|
|
2251
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
2252
|
+
st.loadFeatures(),
|
|
2253
|
+
st.loadAllPhases(),
|
|
2254
|
+
st.loadProject(),
|
|
2255
|
+
st.loadResume(),
|
|
2256
|
+
st.listHandoffs(),
|
|
2257
|
+
st.listArchivedHandoffs(),
|
|
2258
|
+
]);
|
|
2259
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
2260
|
+
handoffs: handoffs.map((handoff) => ({
|
|
2261
|
+
compositeRef: handoff.compositeRef,
|
|
2262
|
+
firstLine: handoff.firstLine,
|
|
2263
|
+
resumeReady: handoff.resumeReady,
|
|
2264
|
+
})),
|
|
2265
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
2266
|
+
});
|
|
2267
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
2268
|
+
ctx.ui.notify(reply.text, "info");
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2483
2271
|
if (b === "start") {
|
|
2484
2272
|
return st.runBatch(async () => {
|
|
2485
2273
|
let phase = null;
|
|
@@ -2765,11 +2553,7 @@ export default function planPiExtension(pi) {
|
|
|
2765
2553
|
return;
|
|
2766
2554
|
}
|
|
2767
2555
|
if (action === "prepare") {
|
|
2768
|
-
|
|
2769
|
-
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2770
|
-
return;
|
|
2771
|
-
}
|
|
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" +
|
|
2556
|
+
const handoffPrepared = sendToAgent("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" +
|
|
2773
2557
|
"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" +
|
|
2774
2558
|
"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" +
|
|
2775
2559
|
"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" +
|
|
@@ -2783,7 +2567,9 @@ export default function planPiExtension(pi) {
|
|
|
2783
2567
|
"- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
|
|
2784
2568
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2785
2569
|
"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.");
|
|
2786
|
-
ctx.ui.notify(
|
|
2570
|
+
ctx.ui.notify(handoffPrepared
|
|
2571
|
+
? "Instructing the agent to prepare the phase handoff…"
|
|
2572
|
+
: "Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", handoffPrepared ? "info" : "warning");
|
|
2787
2573
|
return;
|
|
2788
2574
|
}
|
|
2789
2575
|
if (action === "clear" || action === "delete") {
|
|
@@ -2944,23 +2730,38 @@ export default function planPiExtension(pi) {
|
|
|
2944
2730
|
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
2945
2731
|
// triggerTurn messages), so the recap data must live here. Uses the shared
|
|
2946
2732
|
// core buildRecap so Pi and Claude Code/Codex present identical content.
|
|
2947
|
-
|
|
2948
|
-
|
|
2733
|
+
// One text, each piece once — the same builder MCP's planner-load and
|
|
2734
|
+
// Pi's planner-load tool use. In Pi this command is the only way the
|
|
2735
|
+
// operating guide reaches the agent, so the guide body is included.
|
|
2736
|
+
let loadText = "";
|
|
2949
2737
|
try {
|
|
2950
2738
|
const plannerSkill = await st.syncPlannerSkill();
|
|
2951
2739
|
await st.syncGrillMeSkill();
|
|
2952
2740
|
const srv = server;
|
|
2953
|
-
recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2741
|
+
let recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2954
2742
|
if (preparation.changed)
|
|
2955
2743
|
recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
|
|
2956
|
-
|
|
2744
|
+
// Explicit planner load must deliver the complete project-level
|
|
2745
|
+
// context (P102(F005)/T404), attested through the same lossless
|
|
2746
|
+
// contract used everywhere else.
|
|
2747
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
|
|
2748
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
|
|
2749
|
+
if (projectContextReply.structured.contextComplete) {
|
|
2750
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
2751
|
+
}
|
|
2752
|
+
loadText = buildPlannerLoadReply({
|
|
2753
|
+
recap: recapText,
|
|
2754
|
+
projectContext: projectContextReply,
|
|
2755
|
+
scopedDecisions: buildScopedDecisionContext(await st.loadAll()),
|
|
2756
|
+
plannerGuide: { mode: "include", content: plannerSkill.content, customized: plannerSkill.customized, message: plannerSkill.message },
|
|
2757
|
+
}).text;
|
|
2957
2758
|
}
|
|
2958
2759
|
catch (e) {
|
|
2959
|
-
|
|
2760
|
+
loadText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
2960
2761
|
}
|
|
2961
2762
|
pi.sendMessage({
|
|
2962
2763
|
customType: "planner-resume-trigger",
|
|
2963
|
-
content: "[internal trigger — not a user command] Read and retain the agent-only
|
|
2764
|
+
content: "[internal trigger — not a user command] Read and retain the agent-only context below, then present the recap section 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" + loadText,
|
|
2964
2765
|
display: false,
|
|
2965
2766
|
}, {
|
|
2966
2767
|
triggerTurn: true,
|
|
@@ -3057,7 +2858,7 @@ export default function planPiExtension(pi) {
|
|
|
3057
2858
|
description: "Initialize a new project plan (.planner/) in the current cwd. Use once at the start of a project. When deriving the planner from an existing document or plan, use a concise human project title and ask the user to confirm it if your candidate title is long, compound, or looks like a phase heading.",
|
|
3058
2859
|
parameters: Type.Object({
|
|
3059
2860
|
projectName: Type.String({ description: "Concise project name/title" }),
|
|
3060
|
-
description: Type.Optional(Type.String({ description:
|
|
2861
|
+
description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3061
2862
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
3062
2863
|
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." })),
|
|
3063
2864
|
}),
|
|
@@ -3086,7 +2887,7 @@ export default function planPiExtension(pi) {
|
|
|
3086
2887
|
if (requirementTitles.length > 0) {
|
|
3087
2888
|
const now = nowISO();
|
|
3088
2889
|
await st.saveRequirements({
|
|
3089
|
-
requirements: requirementTitles.map((title
|
|
2890
|
+
requirements: requirementTitles.map((title) => ({
|
|
3090
2891
|
id: createRequirementId(),
|
|
3091
2892
|
title,
|
|
3092
2893
|
description: "",
|
|
@@ -3111,7 +2912,7 @@ export default function planPiExtension(pi) {
|
|
|
3111
2912
|
description: "Update project-level metadata such as title, description, goal, scope, technologies, and rules. Use this after importing or refining an existing plan so the planner root is not left empty.",
|
|
3112
2913
|
parameters: Type.Object({
|
|
3113
2914
|
name: Type.Optional(Type.String({ description: "Project title" })),
|
|
3114
|
-
description: Type.Optional(Type.String({ description:
|
|
2915
|
+
description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3115
2916
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
|
|
3116
2917
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
3117
2918
|
scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
|
|
@@ -3119,17 +2920,24 @@ export default function planPiExtension(pi) {
|
|
|
3119
2920
|
technologies: Type.Optional(Type.Array(Type.String(), { description: "Replace technologies list" })),
|
|
3120
2921
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Replace tools list" })),
|
|
3121
2922
|
globalRules: Type.Optional(Type.Array(Type.String(), { description: "Replace global rules" })),
|
|
3122
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
2923
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
|
|
3123
2924
|
}),
|
|
3124
2925
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3125
2926
|
const st = await requirePlan(ctx);
|
|
3126
2927
|
if (!st)
|
|
3127
2928
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
2929
|
+
if (params.decisions !== undefined) {
|
|
2930
|
+
return {
|
|
2931
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
2932
|
+
details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" },
|
|
2933
|
+
isError: true,
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
3128
2936
|
const project = await st.loadProject();
|
|
3129
|
-
const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"
|
|
2937
|
+
const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
|
|
3130
2938
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3131
2939
|
if (receivedFields.length === 0) {
|
|
3132
|
-
return
|
|
2940
|
+
return piMutationNoFieldsFailure({
|
|
3133
2941
|
entity: "project",
|
|
3134
2942
|
ref: project.name,
|
|
3135
2943
|
operation: "update",
|
|
@@ -3166,8 +2974,6 @@ export default function planPiExtension(pi) {
|
|
|
3166
2974
|
project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
|
|
3167
2975
|
if (params.globalRules !== undefined)
|
|
3168
2976
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
3169
|
-
if (params.decisions !== undefined)
|
|
3170
|
-
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
3171
2977
|
const persisted = await st.updateProject((current) => {
|
|
3172
2978
|
const next = {
|
|
3173
2979
|
...current,
|
|
@@ -3179,7 +2985,6 @@ export default function planPiExtension(pi) {
|
|
|
3179
2985
|
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3180
2986
|
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3181
2987
|
...(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
2988
|
};
|
|
3184
2989
|
if (params.descriptionRef !== undefined) {
|
|
3185
2990
|
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
@@ -3191,7 +2996,14 @@ export default function planPiExtension(pi) {
|
|
|
3191
2996
|
return next;
|
|
3192
2997
|
});
|
|
3193
2998
|
await st.writeGenerated();
|
|
3194
|
-
|
|
2999
|
+
const projectUpdateReply = buildMutationReply({
|
|
3000
|
+
identity: { entity: "project", ref: persisted.name, title: persisted.name },
|
|
3001
|
+
operation: "update",
|
|
3002
|
+
updatedFields: receivedFields,
|
|
3003
|
+
changedValues: persisted,
|
|
3004
|
+
readBackCommand: "plan_get",
|
|
3005
|
+
});
|
|
3006
|
+
return { content: [{ type: "text", text: projectUpdateReply.text }], details: projectUpdateReply.structured };
|
|
3195
3007
|
},
|
|
3196
3008
|
});
|
|
3197
3009
|
pi.registerTool({
|
|
@@ -3333,7 +3145,7 @@ export default function planPiExtension(pi) {
|
|
|
3333
3145
|
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.",
|
|
3334
3146
|
parameters: Type.Object({
|
|
3335
3147
|
title: Type.String({ description: "Requirement title" }),
|
|
3336
|
-
description: Type.Optional(Type.String({ description:
|
|
3148
|
+
description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3337
3149
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
|
|
3338
3150
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3339
3151
|
title: Type.String({ description: "Macro-task title" }),
|
|
@@ -3392,7 +3204,7 @@ export default function planPiExtension(pi) {
|
|
|
3392
3204
|
parameters: Type.Object({
|
|
3393
3205
|
requirementId: Type.String({ description: "Requirement ID" }),
|
|
3394
3206
|
title: Type.Optional(Type.String({ description: "Requirement title" })),
|
|
3395
|
-
description: Type.Optional(Type.String({ description:
|
|
3207
|
+
description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3396
3208
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
|
|
3397
3209
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3398
3210
|
id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
|
|
@@ -3413,7 +3225,7 @@ export default function planPiExtension(pi) {
|
|
|
3413
3225
|
return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
|
|
3414
3226
|
}
|
|
3415
3227
|
if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
|
|
3416
|
-
return
|
|
3228
|
+
return piMutationNoFieldsFailure({
|
|
3417
3229
|
entity: "requirement",
|
|
3418
3230
|
ref: params.requirementId,
|
|
3419
3231
|
operation: "update",
|
|
@@ -3421,6 +3233,12 @@ export default function planPiExtension(pi) {
|
|
|
3421
3233
|
retryCommand: `requirement_update ${params.requirementId}`,
|
|
3422
3234
|
});
|
|
3423
3235
|
}
|
|
3236
|
+
const receivedFields = [
|
|
3237
|
+
...(params.title !== undefined ? ["title"] : []),
|
|
3238
|
+
...(params.description !== undefined ? ["description"] : []),
|
|
3239
|
+
...(params.linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
|
|
3240
|
+
...(params.macroTasks !== undefined ? ["macroTasks"] : []),
|
|
3241
|
+
];
|
|
3424
3242
|
const links = params.linkedPhaseIds === undefined
|
|
3425
3243
|
? undefined
|
|
3426
3244
|
: await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
@@ -3448,7 +3266,14 @@ export default function planPiExtension(pi) {
|
|
|
3448
3266
|
return { content: [{ type: "text", text: message }], details: {} };
|
|
3449
3267
|
}
|
|
3450
3268
|
await st.writeGenerated();
|
|
3451
|
-
|
|
3269
|
+
const requirementUpdateReply = buildMutationReply({
|
|
3270
|
+
identity: { entity: "requirement", ref: persisted.id, id: persisted.id, title: persisted.title },
|
|
3271
|
+
operation: "update",
|
|
3272
|
+
updatedFields: receivedFields,
|
|
3273
|
+
changedValues: persisted,
|
|
3274
|
+
readBackCommand: "requirement_list",
|
|
3275
|
+
});
|
|
3276
|
+
return { content: [{ type: "text", text: requirementUpdateReply.text }], details: requirementUpdateReply.structured };
|
|
3452
3277
|
},
|
|
3453
3278
|
});
|
|
3454
3279
|
pi.registerTool({
|
|
@@ -3495,7 +3320,7 @@ export default function planPiExtension(pi) {
|
|
|
3495
3320
|
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3496
3321
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
|
|
3497
3322
|
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
|
|
3323
|
+
return piMutationNoFieldsFailure({ entity: "idea", ref: params.idea, operation: "update", mutableFields: ["title", "description"], retryCommand: `idea_update ${params.idea}` }); const ideaUpdatedFields = [...(params.title !== undefined ? ["title"] : []), ...(params.description !== undefined ? ["description"] : [])]; const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); const ideaReply = buildMutationReply({ identity: { entity: "idea", ref: formatIdeaRef(idea.number), id: idea.id, title: idea.title }, operation: "update", updatedFields: ideaUpdatedFields, changedValues: idea, readBackCommand: `idea_show ${formatIdeaRef(idea.number)}` }); return { content: [{ type: "text", text: ideaReply.text }], details: ideaReply.structured }; },
|
|
3499
3324
|
});
|
|
3500
3325
|
pi.registerTool({
|
|
3501
3326
|
name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
|
|
@@ -3568,9 +3393,20 @@ export default function planPiExtension(pi) {
|
|
|
3568
3393
|
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
3569
3394
|
: `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
|
|
3570
3395
|
const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
|
|
3396
|
+
// P104(F005)/T422: a phase can be left in-progress with no active task
|
|
3397
|
+
// and nothing else said so on this cheap read — render that fact,
|
|
3398
|
+
// priority-ordered, the same shape planner-show renders on the MCP side.
|
|
3399
|
+
const openPhaseWork = listOpenPhaseWork(plan.phases, plan.features.features);
|
|
3400
|
+
const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
|
|
3571
3401
|
return {
|
|
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: {
|
|
3402
|
+
content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements. ${activeTaskEvidence}${openPhaseWorkText ? `\nIn-progress phases with work left (priority order):\n${openPhaseWorkText}` : ""}\n${projectDecisionContext.content}` }],
|
|
3403
|
+
details: {
|
|
3404
|
+
...plan,
|
|
3405
|
+
activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict",
|
|
3406
|
+
activeTasks,
|
|
3407
|
+
openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
|
|
3408
|
+
openPhaseWorkTotal: openPhaseWork.length,
|
|
3409
|
+
},
|
|
3574
3410
|
};
|
|
3575
3411
|
},
|
|
3576
3412
|
});
|
|
@@ -3798,53 +3634,27 @@ export default function planPiExtension(pi) {
|
|
|
3798
3634
|
if (!r.ok)
|
|
3799
3635
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3800
3636
|
const phase = await st.loadPhase(r.phase.id);
|
|
3801
|
-
|
|
3802
|
-
if (!bounded.content.trim()) {
|
|
3637
|
+
if (!phase.handoff.trim()) {
|
|
3803
3638
|
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
3804
3639
|
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
content:
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
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 } };
|
|
3640
|
+
const reply = archived
|
|
3641
|
+
? buildHandoffShowReply({
|
|
3642
|
+
kind: "archived",
|
|
3643
|
+
phaseRef: r.compositeRef,
|
|
3644
|
+
phaseId: phase.id,
|
|
3645
|
+
content: archived.content,
|
|
3646
|
+
archiveReason: archived.reason,
|
|
3647
|
+
archivedAt: archived.archivedAt,
|
|
3648
|
+
archiveFile: archived.file,
|
|
3649
|
+
handoffAudit: phase.handoffAudit,
|
|
3650
|
+
})
|
|
3651
|
+
: buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
|
|
3652
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3833
3653
|
}
|
|
3834
3654
|
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
3835
3655
|
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
3836
|
-
const
|
|
3837
|
-
|
|
3838
|
-
&& phase.handoffAudit.contentHash === contentHash
|
|
3839
|
-
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
3840
|
-
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
3841
|
-
const status = resumeReady
|
|
3842
|
-
? "Resume-ready: persisted compact capsule read-back completed."
|
|
3843
|
-
: "NOT resume-ready: after reading this persisted capsule, call handoff_verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
|
|
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
|
-
};
|
|
3656
|
+
const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
|
|
3657
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3848
3658
|
},
|
|
3849
3659
|
});
|
|
3850
3660
|
pi.registerTool({
|
|
@@ -3881,7 +3691,7 @@ export default function planPiExtension(pi) {
|
|
|
3881
3691
|
}
|
|
3882
3692
|
catch (error) {
|
|
3883
3693
|
if (error instanceof HandoffContractError)
|
|
3884
|
-
return
|
|
3694
|
+
return piHandoffContractFailure(error);
|
|
3885
3695
|
const message = error instanceof Error ? error.message : String(error);
|
|
3886
3696
|
return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
|
|
3887
3697
|
}
|
|
@@ -3891,7 +3701,13 @@ export default function planPiExtension(pi) {
|
|
|
3891
3701
|
name: "handoff_prepare",
|
|
3892
3702
|
label: "Handoff Prepare",
|
|
3893
3703
|
description: "Prepare one exact phase before writing a handoff. Returns a compact resume capsule scaffold, bounded phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; do not duplicate planner evidence in Markdown.",
|
|
3894
|
-
parameters: Type.Object({
|
|
3704
|
+
parameters: Type.Object({
|
|
3705
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3706
|
+
supportingDocuments: Type.Optional(Type.Array(Type.Object({
|
|
3707
|
+
path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
|
|
3708
|
+
description: Type.String({ description: "What the document contains and why the next agent needs it." }),
|
|
3709
|
+
}), { description: "Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically." })),
|
|
3710
|
+
}),
|
|
3895
3711
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3896
3712
|
const st = await requirePlan(ctx);
|
|
3897
3713
|
if (!st)
|
|
@@ -3900,42 +3716,13 @@ export default function planPiExtension(pi) {
|
|
|
3900
3716
|
if (!r.ok)
|
|
3901
3717
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3902
3718
|
try {
|
|
3903
|
-
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3904
|
-
const
|
|
3905
|
-
|
|
3906
|
-
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
3907
|
-
const existing = boundedExisting.content.trim() || "(none)";
|
|
3908
|
-
return {
|
|
3909
|
-
content: [{ type: "text", text: [
|
|
3910
|
-
`Handoff preparation audit for ${r.compositeRef}`,
|
|
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
|
-
"",
|
|
3918
|
-
"Done tasks missing durable completion/verification evidence:",
|
|
3919
|
-
missing,
|
|
3920
|
-
"",
|
|
3921
|
-
"Compact handoff contract: include only the exact focus/resume point, current or partial state, preservation constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
|
|
3922
|
-
"No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
|
|
3923
|
-
"Completeness audit and cold-start evidence are planner-owned metadata. Do not copy their categories or source reviews into Markdown; provide legacy evidence only when it already exists.",
|
|
3924
|
-
`Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
|
|
3925
|
-
"",
|
|
3926
|
-
"Existing active handoff (reconcile all still-relevant content):",
|
|
3927
|
-
existing,
|
|
3928
|
-
].join("\n") }],
|
|
3929
|
-
details: {
|
|
3930
|
-
phaseRef: r.compositeRef,
|
|
3931
|
-
...auditDetails,
|
|
3932
|
-
evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
|
|
3933
|
-
existingHandoffLength: boundedExisting.fullLength,
|
|
3934
|
-
existingHandoffTruncated: boundedExisting.truncated,
|
|
3935
|
-
},
|
|
3936
|
-
};
|
|
3719
|
+
const audit = await st.preparePhaseHandoff(r.phase.id, params.supportingDocuments);
|
|
3720
|
+
const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
|
|
3721
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3937
3722
|
}
|
|
3938
3723
|
catch (error) {
|
|
3724
|
+
if (error instanceof HandoffContractError)
|
|
3725
|
+
return piHandoffContractFailure(error, "preparation");
|
|
3939
3726
|
const message = error instanceof Error ? error.message : String(error);
|
|
3940
3727
|
return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
|
|
3941
3728
|
}
|
|
@@ -3944,16 +3731,20 @@ export default function planPiExtension(pi) {
|
|
|
3944
3731
|
pi.registerTool({
|
|
3945
3732
|
name: "handoff_write",
|
|
3946
3733
|
label: "Handoff Write",
|
|
3947
|
-
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
|
|
3734
|
+
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown. On failure the submitted content is retained against phaseRef + expectedHandoffUpdatedAt: a retry against the exact same token may omit content and markdown_content to reuse it instead of resending the whole body.",
|
|
3948
3735
|
parameters: Type.Object({
|
|
3949
3736
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3950
|
-
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3737
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Ignored when content and markdown_content are both omitted (the retained title is reused)." })),
|
|
3951
3738
|
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." })),
|
|
3952
3739
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3953
|
-
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3954
|
-
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
3740
|
+
content: Type.Optional(Type.String({ description: "Handoff text (plain). Omit (with markdown_content) only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the body retained from that failed attempt." })),
|
|
3741
|
+
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content. Omit (with content) only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the retained body." })),
|
|
3955
3742
|
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3956
3743
|
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3744
|
+
sectionDispositions: Type.Optional(Type.Array(Type.Object({
|
|
3745
|
+
section: Type.String({ description: "Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal." }),
|
|
3746
|
+
disposition: Type.String({ description: "What happened to that section: dropped, superseded, or folded into another section." }),
|
|
3747
|
+
}), { description: "Required only when a prior write was refused with HANDOFF_SECTION_RECONCILIATION_REQUIRED; one entry per section it named. Omit on a first write." })),
|
|
3957
3748
|
completenessAudit: Type.Optional(Type.Object({
|
|
3958
3749
|
version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
3959
3750
|
entries: Type.Array(Type.Object({
|
|
@@ -3999,24 +3790,29 @@ export default function planPiExtension(pi) {
|
|
|
3999
3790
|
const st = await requirePlan(ctx);
|
|
4000
3791
|
if (!st)
|
|
4001
3792
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
3793
|
+
// content/markdown_content are optional so a retry after a failed write
|
|
3794
|
+
// can omit them and reuse the body PlanStore retained against this
|
|
3795
|
+
// phase + expectedHandoffUpdatedAt (see PlanStore.refreshPhaseHandoff).
|
|
3796
|
+
// body stays undefined only in that case; the title/heading checks
|
|
3797
|
+
// below apply only to freshly-submitted text.
|
|
3798
|
+
let body = (params.markdown_content ?? params.content ?? "").trim() || undefined;
|
|
4005
3799
|
const title = params.title?.trim();
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
lines[firstIdx]
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
3800
|
+
if (body !== undefined) {
|
|
3801
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
3802
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3803
|
+
const effectiveHeadingText = title || firstHeadingText;
|
|
3804
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
3805
|
+
return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
|
|
3806
|
+
}
|
|
3807
|
+
if (title) {
|
|
3808
|
+
const lines = body.split(/\r?\n/);
|
|
3809
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
3810
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3811
|
+
lines[firstIdx] = `# ${title}`;
|
|
3812
|
+
else
|
|
3813
|
+
lines.unshift(`# ${title}`, "");
|
|
3814
|
+
body = lines.join("\n");
|
|
3815
|
+
}
|
|
4020
3816
|
}
|
|
4021
3817
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
4022
3818
|
if (!r.ok)
|
|
@@ -4046,9 +3842,10 @@ export default function planPiExtension(pi) {
|
|
|
4046
3842
|
try {
|
|
4047
3843
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
4048
3844
|
reason: params.reason.trim(),
|
|
4049
|
-
content: body,
|
|
3845
|
+
...(body !== undefined ? { content: body } : {}),
|
|
4050
3846
|
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
4051
3847
|
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3848
|
+
...(params.sectionDispositions ? { sectionDispositions: params.sectionDispositions } : {}),
|
|
4052
3849
|
...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
|
|
4053
3850
|
...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
|
|
4054
3851
|
...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
|
|
@@ -4085,9 +3882,9 @@ export default function planPiExtension(pi) {
|
|
|
4085
3882
|
}
|
|
4086
3883
|
catch (error) {
|
|
4087
3884
|
if (error instanceof HandoffContractError)
|
|
4088
|
-
return
|
|
3885
|
+
return piHandoffContractFailure(error);
|
|
4089
3886
|
const message = error instanceof Error ? error.message : String(error);
|
|
4090
|
-
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}
|
|
3887
|
+
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message} If you retry against the same phaseRef and expectedHandoffUpdatedAt, you may omit content/markdown_content to reuse the body just submitted.` }], details: { error: message } };
|
|
4091
3888
|
}
|
|
4092
3889
|
},
|
|
4093
3890
|
});
|
|
@@ -4219,7 +4016,6 @@ export default function planPiExtension(pi) {
|
|
|
4219
4016
|
return st.runBatch(async () => {
|
|
4220
4017
|
const now = nowISO();
|
|
4221
4018
|
const status = params.status ?? "planned";
|
|
4222
|
-
const currentFeatures = (await st.loadFeatures()).features;
|
|
4223
4019
|
const id = createFeatureId();
|
|
4224
4020
|
const identity = await st.allocateEntityIdentity("feature", id);
|
|
4225
4021
|
const priority = await st.nextPriority("feature");
|
|
@@ -4264,7 +4060,7 @@ export default function planPiExtension(pi) {
|
|
|
4264
4060
|
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
4265
4061
|
parameters: Type.Object({
|
|
4266
4062
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4267
|
-
description: Type.Optional(Type.String({ description:
|
|
4063
|
+
description: Type.Optional(Type.String({ description: `Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4268
4064
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4269
4065
|
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
4270
4066
|
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
@@ -4282,7 +4078,7 @@ export default function planPiExtension(pi) {
|
|
|
4282
4078
|
const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
|
|
4283
4079
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4284
4080
|
if (receivedFields.length === 0) {
|
|
4285
|
-
return
|
|
4081
|
+
return piMutationNoFieldsFailure({
|
|
4286
4082
|
entity: "feature",
|
|
4287
4083
|
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4288
4084
|
operation: "discuss",
|
|
@@ -4326,7 +4122,14 @@ export default function planPiExtension(pi) {
|
|
|
4326
4122
|
if (!feature)
|
|
4327
4123
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
4328
4124
|
await st.writeGenerated();
|
|
4329
|
-
|
|
4125
|
+
const featureDiscussReply = buildMutationReply({
|
|
4126
|
+
identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
|
|
4127
|
+
operation: "discuss",
|
|
4128
|
+
updatedFields: receivedFields,
|
|
4129
|
+
changedValues: feature,
|
|
4130
|
+
readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
|
|
4131
|
+
});
|
|
4132
|
+
return { content: [{ type: "text", text: featureDiscussReply.text }], details: featureDiscussReply.structured };
|
|
4330
4133
|
},
|
|
4331
4134
|
});
|
|
4332
4135
|
pi.registerTool({
|
|
@@ -4336,7 +4139,7 @@ export default function planPiExtension(pi) {
|
|
|
4336
4139
|
parameters: Type.Object({
|
|
4337
4140
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4338
4141
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
4339
|
-
description: Type.Optional(Type.String({ description:
|
|
4142
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4340
4143
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4341
4144
|
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." })),
|
|
4342
4145
|
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
@@ -4363,12 +4166,12 @@ export default function planPiExtension(pi) {
|
|
|
4363
4166
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4364
4167
|
const featureId = resolvedFeature.feature.id;
|
|
4365
4168
|
if (params.status !== undefined) {
|
|
4366
|
-
return
|
|
4169
|
+
return piDerivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
4367
4170
|
}
|
|
4368
4171
|
if (params.acceptedDecisions !== undefined) {
|
|
4369
4172
|
return {
|
|
4370
|
-
content: [{ type: "text", text:
|
|
4371
|
-
details: { updated: false, errorCode:
|
|
4173
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
4174
|
+
details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
|
|
4372
4175
|
isError: true,
|
|
4373
4176
|
};
|
|
4374
4177
|
}
|
|
@@ -4384,7 +4187,7 @@ export default function planPiExtension(pi) {
|
|
|
4384
4187
|
];
|
|
4385
4188
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4386
4189
|
if (receivedFields.length === 0) {
|
|
4387
|
-
return
|
|
4190
|
+
return piMutationNoFieldsFailure({
|
|
4388
4191
|
entity: "feature",
|
|
4389
4192
|
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4390
4193
|
operation: "update",
|
|
@@ -4442,7 +4245,13 @@ export default function planPiExtension(pi) {
|
|
|
4442
4245
|
type: "text",
|
|
4443
4246
|
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
4444
4247
|
}],
|
|
4445
|
-
details: {
|
|
4248
|
+
details: buildMutationReply({
|
|
4249
|
+
identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
|
|
4250
|
+
operation: "update",
|
|
4251
|
+
updatedFields: receivedFields,
|
|
4252
|
+
changedValues: feature,
|
|
4253
|
+
readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
|
|
4254
|
+
}).structured,
|
|
4446
4255
|
};
|
|
4447
4256
|
},
|
|
4448
4257
|
});
|
|
@@ -4462,27 +4271,15 @@ export default function planPiExtension(pi) {
|
|
|
4462
4271
|
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
4463
4272
|
if (!resolvedFeature.ok)
|
|
4464
4273
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4465
|
-
const
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
return doc;
|
|
4475
|
-
});
|
|
4476
|
-
if (params.cascade) {
|
|
4477
|
-
const phases = await st.loadAllPhases();
|
|
4478
|
-
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
4479
|
-
await st.deletePhase(phase.id);
|
|
4480
|
-
cascadeCount += 1;
|
|
4481
|
-
}
|
|
4482
|
-
}
|
|
4483
|
-
await st.writeGenerated();
|
|
4484
|
-
});
|
|
4485
|
-
return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
|
|
4274
|
+
const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(params.cascade) });
|
|
4275
|
+
if (!outcome.ok) {
|
|
4276
|
+
return { content: [{ type: "text", text: `Feature delete failed: ${outcome.error}` }], details: { deleted: false, errorCode: outcome.errorCode }, isError: true };
|
|
4277
|
+
}
|
|
4278
|
+
const { result } = outcome;
|
|
4279
|
+
return {
|
|
4280
|
+
content: [{ type: "text", text: `Feature deleted: ${result.ref}${result.cascade ? ` (cascade: ${result.phaseCount} phases)` : result.phaseCount > 0 ? ` (unlinked: ${result.phaseCount} phases)` : ""}` }],
|
|
4281
|
+
details: { deleted: true, ref: result.ref, id: result.id, cascadedPhases: result.cascade ? result.phaseCount : 0, unlinkedPhases: result.cascade ? 0 : result.phaseCount },
|
|
4282
|
+
};
|
|
4486
4283
|
},
|
|
4487
4284
|
});
|
|
4488
4285
|
// ── phases ──────────────────────────────────────────────────────────
|
|
@@ -4660,7 +4457,7 @@ export default function planPiExtension(pi) {
|
|
|
4660
4457
|
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4661
4458
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4662
4459
|
if (receivedFields.length === 0) {
|
|
4663
|
-
return
|
|
4460
|
+
return piMutationNoFieldsFailure({
|
|
4664
4461
|
entity: "phase",
|
|
4665
4462
|
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4666
4463
|
operation: "discuss",
|
|
@@ -4709,7 +4506,15 @@ export default function planPiExtension(pi) {
|
|
|
4709
4506
|
});
|
|
4710
4507
|
await st.writeGenerated();
|
|
4711
4508
|
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
4712
|
-
|
|
4509
|
+
const phaseDiscussReply = buildMutationReply({
|
|
4510
|
+
identity: { entity: "phase", ref: phaseRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
4511
|
+
operation: "discuss",
|
|
4512
|
+
updatedFields: receivedFields,
|
|
4513
|
+
changedValues: phase,
|
|
4514
|
+
extras: { contextReady: true },
|
|
4515
|
+
readBackCommand: `phase_get ${phaseRef}`,
|
|
4516
|
+
});
|
|
4517
|
+
return { content: [{ type: "text", text: phaseDiscussReply.text }], details: phaseDiscussReply.structured };
|
|
4713
4518
|
},
|
|
4714
4519
|
});
|
|
4715
4520
|
pi.registerTool({
|
|
@@ -4721,7 +4526,7 @@ export default function planPiExtension(pi) {
|
|
|
4721
4526
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
4722
4527
|
status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
|
|
4723
4528
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
4724
|
-
description: Type.Optional(Type.String({ description:
|
|
4529
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4725
4530
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4726
4531
|
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
4727
4532
|
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
@@ -4730,7 +4535,7 @@ export default function planPiExtension(pi) {
|
|
|
4730
4535
|
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
|
|
4731
4536
|
risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
|
|
4732
4537
|
openQuestions: Type.Optional(Type.Array(Type.String(), { description: "Replace open questions list" })),
|
|
4733
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
4538
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
|
|
4734
4539
|
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
4735
4540
|
id: Type.String(),
|
|
4736
4541
|
title: Type.String(),
|
|
@@ -4758,19 +4563,26 @@ export default function planPiExtension(pi) {
|
|
|
4758
4563
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4759
4564
|
}
|
|
4760
4565
|
if (params.status !== undefined) {
|
|
4761
|
-
return
|
|
4566
|
+
return piDerivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
4762
4567
|
}
|
|
4763
4568
|
if (params.acceptedDecisions !== undefined) {
|
|
4764
4569
|
return {
|
|
4765
|
-
content: [{ type: "text", text:
|
|
4766
|
-
details: { updated: false, errorCode:
|
|
4570
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
4571
|
+
details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4767
4572
|
isError: true,
|
|
4768
4573
|
};
|
|
4769
4574
|
}
|
|
4770
|
-
|
|
4575
|
+
if (params.decisions !== undefined) {
|
|
4576
|
+
return {
|
|
4577
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
4578
|
+
details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4579
|
+
isError: true,
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4771
4583
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4772
4584
|
if (receivedFields.length === 0) {
|
|
4773
|
-
return
|
|
4585
|
+
return piMutationNoFieldsFailure({
|
|
4774
4586
|
entity: "phase",
|
|
4775
4587
|
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4776
4588
|
operation: "update",
|
|
@@ -4812,7 +4624,6 @@ export default function planPiExtension(pi) {
|
|
|
4812
4624
|
...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4813
4625
|
...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4814
4626
|
...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4815
|
-
...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4816
4627
|
...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4817
4628
|
...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
|
|
4818
4629
|
updatedAt: nowISO(),
|
|
@@ -4847,13 +4658,22 @@ export default function planPiExtension(pi) {
|
|
|
4847
4658
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
4848
4659
|
.filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
|
|
4849
4660
|
.map((entry) => entry.ownerRef) ?? [];
|
|
4850
|
-
|
|
4661
|
+
const phaseUpdateRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures));
|
|
4662
|
+
const phaseUpdateReply = buildMutationReply({
|
|
4663
|
+
identity: { entity: "phase", ref: phaseUpdateRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
4664
|
+
operation: "update",
|
|
4665
|
+
updatedFields: receivedFields,
|
|
4666
|
+
changedValues: phase,
|
|
4667
|
+
extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
|
|
4668
|
+
readBackCommand: `phase_get ${phaseUpdateRef}`,
|
|
4669
|
+
});
|
|
4670
|
+
return { content: [{ type: "text", text: phaseUpdateReply.text }], details: phaseUpdateReply.structured };
|
|
4851
4671
|
},
|
|
4852
4672
|
});
|
|
4853
4673
|
pi.registerTool({
|
|
4854
4674
|
name: "decision_record",
|
|
4855
4675
|
label: "Decision Record",
|
|
4856
|
-
description: "
|
|
4676
|
+
description: "DEPRECATED AND WRITE-DISABLED — retained only for compatibility. It no longer records a decision on both a feature and a phase (a decision has exactly one owner). Call accepted_decision_create instead with the single project/feature/phase/task owner that actually holds this decision; this tool only resolves the given feature/phase and returns that guidance.",
|
|
4857
4677
|
parameters: Type.Object({
|
|
4858
4678
|
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
4859
4679
|
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
@@ -4876,49 +4696,17 @@ export default function planPiExtension(pi) {
|
|
|
4876
4696
|
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
4877
4697
|
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
4878
4698
|
}
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
try {
|
|
4891
|
-
await st.updateFeatures((doc) => {
|
|
4892
|
-
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
4893
|
-
if (!feature)
|
|
4894
|
-
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
4895
|
-
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
4896
|
-
feature.updatedAt = acceptedAt;
|
|
4897
|
-
return doc;
|
|
4898
|
-
});
|
|
4899
|
-
featureWritten = true;
|
|
4900
|
-
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
4901
|
-
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
4902
|
-
phase.updatedAt = acceptedAt;
|
|
4903
|
-
return phase;
|
|
4904
|
-
});
|
|
4905
|
-
}
|
|
4906
|
-
catch (error) {
|
|
4907
|
-
// Best-effort compensation keeps a failed dual-write from reporting
|
|
4908
|
-
// success with a decision persisted only on the feature.
|
|
4909
|
-
if (featureWritten) {
|
|
4910
|
-
await st.updateFeatures((doc) => {
|
|
4911
|
-
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
4912
|
-
if (feature)
|
|
4913
|
-
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
4914
|
-
return doc;
|
|
4915
|
-
}).catch(() => { });
|
|
4916
|
-
}
|
|
4917
|
-
throw error;
|
|
4918
|
-
}
|
|
4919
|
-
}));
|
|
4920
|
-
await st.writeGenerated();
|
|
4921
|
-
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
4699
|
+
// No write happens here — see buildDecisionRecordRedirectReply. This
|
|
4700
|
+
// tool used to append the same AcceptedDecision to both the feature
|
|
4701
|
+
// and the phase; that violated the single-owner rule and mishandled
|
|
4702
|
+
// project-wide decisions discovered during task work, so it is now a
|
|
4703
|
+
// pure, non-mutating redirect (AGENTS.md rule 4: the rule and its
|
|
4704
|
+
// wording live once, in plan-core, not reinvented per adapter).
|
|
4705
|
+
const reply = buildDecisionRecordRedirectReply({
|
|
4706
|
+
featureRef: formatFeatureRef(resolvedFeature.feature.number),
|
|
4707
|
+
phaseRef: formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number),
|
|
4708
|
+
});
|
|
4709
|
+
return { content: [{ type: "text", text: `⚠️ decision_record is deprecated and write-disabled. ${reply.text}` }], details: { deprecated: true, ...reply.structured } };
|
|
4922
4710
|
},
|
|
4923
4711
|
});
|
|
4924
4712
|
pi.registerTool({
|
|
@@ -4952,7 +4740,7 @@ export default function planPiExtension(pi) {
|
|
|
4952
4740
|
return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
|
|
4953
4741
|
}
|
|
4954
4742
|
catch (error) {
|
|
4955
|
-
return
|
|
4743
|
+
return piAcceptedDecisionMutationFailure(error, "created");
|
|
4956
4744
|
}
|
|
4957
4745
|
},
|
|
4958
4746
|
});
|
|
@@ -5004,7 +4792,7 @@ export default function planPiExtension(pi) {
|
|
|
5004
4792
|
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 } };
|
|
5005
4793
|
}
|
|
5006
4794
|
catch (error) {
|
|
5007
|
-
return
|
|
4795
|
+
return piAcceptedDecisionMutationFailure(error, "updated");
|
|
5008
4796
|
}
|
|
5009
4797
|
},
|
|
5010
4798
|
});
|
|
@@ -5034,7 +4822,7 @@ export default function planPiExtension(pi) {
|
|
|
5034
4822
|
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 } };
|
|
5035
4823
|
}
|
|
5036
4824
|
catch (error) {
|
|
5037
|
-
return
|
|
4825
|
+
return piAcceptedDecisionMutationFailure(error, "deleted");
|
|
5038
4826
|
}
|
|
5039
4827
|
},
|
|
5040
4828
|
});
|
|
@@ -5133,7 +4921,8 @@ export default function planPiExtension(pi) {
|
|
|
5133
4921
|
if (!st)
|
|
5134
4922
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5135
4923
|
const features = (await st.loadFeatures()).features;
|
|
5136
|
-
const
|
|
4924
|
+
const allPhases = await st.loadAllPhases();
|
|
4925
|
+
const found = findTaskByRef(allPhases, features, params.taskId.trim());
|
|
5137
4926
|
if (!found)
|
|
5138
4927
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
5139
4928
|
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})`;
|
|
@@ -5148,7 +4937,15 @@ export default function planPiExtension(pi) {
|
|
|
5148
4937
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
5149
4938
|
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
5150
4939
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
5151
|
-
|
|
4940
|
+
// P104(F005)/T421: priority and dependsOn at the point an agent reads
|
|
4941
|
+
// the task and decides what to do next — the fact task_list and
|
|
4942
|
+
// task_recommend already have, made visible here instead of
|
|
4943
|
+
// requiring a second call.
|
|
4944
|
+
const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
|
|
4945
|
+
const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
|
|
4946
|
+
const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
|
|
4947
|
+
if (completionMismatch)
|
|
4948
|
+
sections.push(completionMismatch);
|
|
5152
4949
|
if (found.task.description?.trim())
|
|
5153
4950
|
sections.push(found.task.description.trim());
|
|
5154
4951
|
if (found.task.descriptionRef)
|
|
@@ -5175,11 +4972,56 @@ export default function planPiExtension(pi) {
|
|
|
5175
4972
|
description: found.task.description,
|
|
5176
4973
|
descriptionRef: found.task.descriptionRef ?? "",
|
|
5177
4974
|
acceptedDecisions: found.task.acceptedDecisions,
|
|
4975
|
+
priority: orderContext.priority,
|
|
4976
|
+
dependsOn: orderContext.dependsOn,
|
|
4977
|
+
...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
|
|
4978
|
+
},
|
|
4979
|
+
taskOrder: {
|
|
4980
|
+
readyCount: orderContext.readyCount,
|
|
4981
|
+
readyAheadCount: orderContext.readyAheadCount,
|
|
4982
|
+
nextByPriorityRef: orderContext.nextByPriorityRef,
|
|
5178
4983
|
},
|
|
5179
4984
|
},
|
|
5180
4985
|
};
|
|
5181
4986
|
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
5182
|
-
|
|
4987
|
+
// Advisory only (P104(F005)/T420): report whether task_start would
|
|
4988
|
+
// succeed right now, using the exact checks it runs itself, so a
|
|
4989
|
+
// caller reading the task before starting it learns what else is
|
|
4990
|
+
// needed in this same call instead of from a denial. task_start
|
|
4991
|
+
// recomputes this at call time and remains the sole authority on
|
|
4992
|
+
// whether work may begin.
|
|
4993
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
4994
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
4995
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
4996
|
+
const taskRef = `${phaseRef}/T${pad(found.task.number)}`;
|
|
4997
|
+
const linkedRequirements = [
|
|
4998
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
4999
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
5000
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
5001
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
5002
|
+
const contextEligibility = contextReadEligibilityForSession({
|
|
5003
|
+
sessionId: plannerSessionId,
|
|
5004
|
+
taskId: found.task.id,
|
|
5005
|
+
phaseId: found.phase.id,
|
|
5006
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5007
|
+
task: found.task,
|
|
5008
|
+
phase: found.phase,
|
|
5009
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
5010
|
+
requirements: linkedRequirements,
|
|
5011
|
+
requirementIds: linkedRequirementIds,
|
|
5012
|
+
});
|
|
5013
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5014
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5015
|
+
const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
|
|
5016
|
+
const gateNextActions = gateState.ready ? [] : piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `task_start ${taskRef}`);
|
|
5017
|
+
return {
|
|
5018
|
+
content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
|
|
5019
|
+
details: {
|
|
5020
|
+
...result.details,
|
|
5021
|
+
taskStartGateState: gateState,
|
|
5022
|
+
...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
|
|
5023
|
+
},
|
|
5024
|
+
};
|
|
5183
5025
|
},
|
|
5184
5026
|
});
|
|
5185
5027
|
pi.registerTool({
|
|
@@ -5270,7 +5112,7 @@ export default function planPiExtension(pi) {
|
|
|
5270
5112
|
});
|
|
5271
5113
|
await st.writeGenerated();
|
|
5272
5114
|
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
5273
|
-
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
5115
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title} (${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
5274
5116
|
},
|
|
5275
5117
|
});
|
|
5276
5118
|
pi.registerTool({
|
|
@@ -5290,7 +5132,8 @@ export default function planPiExtension(pi) {
|
|
|
5290
5132
|
try {
|
|
5291
5133
|
const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5292
5134
|
await st.writeGenerated();
|
|
5293
|
-
|
|
5135
|
+
const reply = buildMutationReply({ identity: { entity: "task", ref: task.id, id: task.id, shortId: task.shortId, title: task.title, status: task.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: task.dependsOn }, readBackCommand: `task_get ${task.id}` });
|
|
5136
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
5294
5137
|
}
|
|
5295
5138
|
catch (error) {
|
|
5296
5139
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -5317,7 +5160,8 @@ export default function planPiExtension(pi) {
|
|
|
5317
5160
|
try {
|
|
5318
5161
|
const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5319
5162
|
await st.writeGenerated();
|
|
5320
|
-
|
|
5163
|
+
const reply = buildMutationReply({ identity: { entity: "task", ref: task.id, id: task.id, shortId: task.shortId, title: task.title, status: task.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: task.dependsOn }, readBackCommand: `task_get ${task.id}` });
|
|
5164
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
5321
5165
|
}
|
|
5322
5166
|
catch (error) {
|
|
5323
5167
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -5333,11 +5177,11 @@ export default function planPiExtension(pi) {
|
|
|
5333
5177
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
5334
5178
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
5335
5179
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
5336
|
-
description: Type.Optional(Type.String({ description:
|
|
5180
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
5337
5181
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
|
|
5338
5182
|
notes: Type.Optional(Type.String({ description: "New implementation notes" })),
|
|
5339
5183
|
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." })),
|
|
5340
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
5184
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
|
|
5341
5185
|
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
5342
5186
|
id: Type.String(),
|
|
5343
5187
|
title: Type.String(),
|
|
@@ -5347,7 +5191,7 @@ export default function planPiExtension(pi) {
|
|
|
5347
5191
|
acceptedAt: Type.String(),
|
|
5348
5192
|
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
5349
5193
|
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
5350
|
-
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
5194
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick); an item that cannot be matched loses its tick and that loss is reported. For interactive toggling use the web UI or checklist_toggle/add/remove." })),
|
|
5351
5195
|
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." })),
|
|
5352
5196
|
}),
|
|
5353
5197
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -5362,6 +5206,7 @@ export default function planPiExtension(pi) {
|
|
|
5362
5206
|
const hostPhase = found.phase;
|
|
5363
5207
|
const now = nowISO();
|
|
5364
5208
|
let updatedTask;
|
|
5209
|
+
let checklistLostTicks = [];
|
|
5365
5210
|
// Validate the motivation requirement for restrictive status transitions,
|
|
5366
5211
|
// mirroring the MCP adapter and the /planner task update command
|
|
5367
5212
|
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
@@ -5379,8 +5224,15 @@ export default function planPiExtension(pi) {
|
|
|
5379
5224
|
}
|
|
5380
5225
|
if (params.acceptedDecisions !== undefined) {
|
|
5381
5226
|
return {
|
|
5382
|
-
content: [{ type: "text", text:
|
|
5383
|
-
details: { updated: false, errorCode:
|
|
5227
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
5228
|
+
details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
|
|
5229
|
+
isError: true,
|
|
5230
|
+
};
|
|
5231
|
+
}
|
|
5232
|
+
if (params.decisions !== undefined) {
|
|
5233
|
+
return {
|
|
5234
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
5235
|
+
details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
|
|
5384
5236
|
isError: true,
|
|
5385
5237
|
};
|
|
5386
5238
|
}
|
|
@@ -5399,10 +5251,10 @@ export default function planPiExtension(pi) {
|
|
|
5399
5251
|
if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
|
|
5400
5252
|
return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
|
|
5401
5253
|
}
|
|
5402
|
-
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "
|
|
5254
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
|
|
5403
5255
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
5404
5256
|
if (receivedFields.length === 0) {
|
|
5405
|
-
return
|
|
5257
|
+
return piMutationNoFieldsFailure({
|
|
5406
5258
|
entity: "task",
|
|
5407
5259
|
ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5408
5260
|
operation: "update",
|
|
@@ -5434,14 +5286,10 @@ export default function planPiExtension(pi) {
|
|
|
5434
5286
|
}
|
|
5435
5287
|
if (params.notes !== undefined)
|
|
5436
5288
|
task.notes = params.notes.trim();
|
|
5437
|
-
if (params.decisions !== undefined)
|
|
5438
|
-
task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
5439
5289
|
if (params.checklist !== undefined) {
|
|
5440
|
-
task.checklist
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
checked: false,
|
|
5444
|
-
}));
|
|
5290
|
+
const replacement = replaceChecklist(task.checklist ?? [], params.checklist, task.id);
|
|
5291
|
+
task.checklist = replacement.items;
|
|
5292
|
+
checklistLostTicks = replacement.lostTicks;
|
|
5445
5293
|
}
|
|
5446
5294
|
if (params.subtasks !== undefined) {
|
|
5447
5295
|
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 }));
|
|
@@ -5485,7 +5333,22 @@ export default function planPiExtension(pi) {
|
|
|
5485
5333
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
5486
5334
|
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
5487
5335
|
.map((entry) => entry.ownerRef) ?? [];
|
|
5488
|
-
|
|
5336
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
5337
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
5338
|
+
: "";
|
|
5339
|
+
const taskUpdateReply = buildMutationReply({
|
|
5340
|
+
identity: { entity: "task", ref: updatedTask.id, id: updatedTask.id, shortId: updatedTask.shortId, title: updatedTask.title, status: updatedTask.status },
|
|
5341
|
+
operation: "update",
|
|
5342
|
+
updatedFields: receivedFields,
|
|
5343
|
+
changedValues: updatedTask,
|
|
5344
|
+
extras: {
|
|
5345
|
+
...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
|
|
5346
|
+
...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
|
|
5347
|
+
},
|
|
5348
|
+
notices: [lostTicksNotice],
|
|
5349
|
+
readBackCommand: `task_get ${updatedTask.id}`,
|
|
5350
|
+
});
|
|
5351
|
+
return { content: [{ type: "text", text: taskUpdateReply.text }], details: taskUpdateReply.structured };
|
|
5489
5352
|
},
|
|
5490
5353
|
});
|
|
5491
5354
|
pi.registerTool({
|
|
@@ -5539,16 +5402,10 @@ export default function planPiExtension(pi) {
|
|
|
5539
5402
|
})),
|
|
5540
5403
|
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
5541
5404
|
});
|
|
5542
|
-
const
|
|
5543
|
-
if (!selection.candidate)
|
|
5544
|
-
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 } };
|
|
5545
|
-
const { candidate } = selection;
|
|
5546
|
-
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
5547
|
-
const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
5548
|
-
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
5405
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
5549
5406
|
return {
|
|
5550
|
-
content: [{ type: "text", text:
|
|
5551
|
-
details:
|
|
5407
|
+
content: [{ type: "text", text: reply.text }],
|
|
5408
|
+
details: reply.structured,
|
|
5552
5409
|
};
|
|
5553
5410
|
},
|
|
5554
5411
|
});
|
|
@@ -5869,7 +5726,8 @@ export default function planPiExtension(pi) {
|
|
|
5869
5726
|
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
5870
5727
|
return st.runBatch(async () => {
|
|
5871
5728
|
const features = (await st.loadFeatures()).features;
|
|
5872
|
-
const
|
|
5729
|
+
const initialPhases = await st.loadAllPhases();
|
|
5730
|
+
const found = findTaskByRef(initialPhases, features, params.taskId.trim());
|
|
5873
5731
|
if (!found)
|
|
5874
5732
|
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
5875
5733
|
const task = found.task;
|
|
@@ -5880,6 +5738,13 @@ export default function planPiExtension(pi) {
|
|
|
5880
5738
|
if (task.status === "done")
|
|
5881
5739
|
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 }));
|
|
5882
5740
|
const project = await st.loadProject();
|
|
5741
|
+
// Targeted stale-context refresh (P102(F005)/T404): task_start delivery
|
|
5742
|
+
// stays scoped to feature/phase/task context, but a session that
|
|
5743
|
+
// already did a full planner-load gets a non-blocking advisory when
|
|
5744
|
+
// project-level context has drifted since, instead of silently going
|
|
5745
|
+
// stale.
|
|
5746
|
+
const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
|
|
5747
|
+
const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
|
|
5883
5748
|
const linkedRequirements = [
|
|
5884
5749
|
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
5885
5750
|
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
@@ -5921,7 +5786,9 @@ export default function planPiExtension(pi) {
|
|
|
5921
5786
|
}
|
|
5922
5787
|
if (task.status === "in-progress") {
|
|
5923
5788
|
const outcome = taskStartSucceeded(task.id, true);
|
|
5924
|
-
|
|
5789
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
5790
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
5791
|
+
return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true${projectContextAdvisory}${phaseOrderAdvisory}` }], details: { ...task, ...outcome, task, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase } };
|
|
5925
5792
|
}
|
|
5926
5793
|
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
5927
5794
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
@@ -6010,9 +5877,11 @@ export default function planPiExtension(pi) {
|
|
|
6010
5877
|
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 }));
|
|
6011
5878
|
}
|
|
6012
5879
|
const outcome = taskStartSucceeded(persistedTask.id);
|
|
5880
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
5881
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
6013
5882
|
return {
|
|
6014
|
-
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
6015
|
-
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
5883
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}` }],
|
|
5884
|
+
details: { ...persistedTask, ...outcome, task: persistedTask, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase },
|
|
6016
5885
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
6017
5886
|
};
|
|
6018
5887
|
});
|
|
@@ -6021,10 +5890,11 @@ export default function planPiExtension(pi) {
|
|
|
6021
5890
|
pi.registerTool({
|
|
6022
5891
|
name: "task_complete",
|
|
6023
5892
|
label: "Task Complete",
|
|
6024
|
-
description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically.
|
|
5893
|
+
description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Fails if checklist is incomplete — tick the completed items with task_checklist_toggle first. force=true is the exception for a checklist that no longer matches the work, and requires a motivation; the overridden items are recorded, never auto-ticked.",
|
|
6025
5894
|
parameters: Type.Object({
|
|
6026
5895
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
6027
5896
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
5897
|
+
motivation: Type.Optional(Type.String({ description: "Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work." })),
|
|
6028
5898
|
description_update: Type.String({ minLength: 10, description: "Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references." }),
|
|
6029
5899
|
}),
|
|
6030
5900
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -6043,13 +5913,18 @@ export default function planPiExtension(pi) {
|
|
|
6043
5913
|
return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
|
|
6044
5914
|
if (task.pauseSnapshot)
|
|
6045
5915
|
return { content: [{ type: "text", text: "Task completion denied: resume checkpointed work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
|
|
6046
|
-
const
|
|
6047
|
-
if (
|
|
5916
|
+
const gate = evaluateTaskCompletionGate(task.checklist, params.force === true, params.motivation);
|
|
5917
|
+
if (!gate.allowed) {
|
|
5918
|
+
const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
|
|
5919
|
+
? taskCompletionForceMotivationRequired(gate.uncheckedItems)
|
|
5920
|
+
: taskCompletionChecklistRefusal(gate.uncheckedItems, `task_checklist_toggle ${params.taskId.trim()} <item>`);
|
|
6048
5921
|
return {
|
|
6049
|
-
content: [{ type: "text", text:
|
|
6050
|
-
details: { task, uncheckedChecklistItems:
|
|
5922
|
+
content: [{ type: "text", text: message }],
|
|
5923
|
+
details: { task, errorCode: gate.errorCode, uncheckedChecklistItems: gate.uncheckedItems },
|
|
6051
5924
|
};
|
|
6052
5925
|
}
|
|
5926
|
+
const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, params.motivation);
|
|
5927
|
+
const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
|
|
6053
5928
|
const now = nowISO();
|
|
6054
5929
|
let completedTask;
|
|
6055
5930
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -6061,10 +5936,10 @@ export default function planPiExtension(pi) {
|
|
|
6061
5936
|
t.statusLog = [...t.statusLog, {
|
|
6062
5937
|
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
6063
5938
|
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
6064
|
-
title: `${previousStatus} → done`, description:
|
|
5939
|
+
title: `${previousStatus} → done`, description: completionEvidence,
|
|
6065
5940
|
}];
|
|
6066
5941
|
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
6067
|
-
t.description = t.description + sep +
|
|
5942
|
+
t.description = t.description + sep + completionEvidence;
|
|
6068
5943
|
t.descriptionUpdatedAt = now;
|
|
6069
5944
|
t.updatedAt = now;
|
|
6070
5945
|
phase.updatedAt = now;
|
|
@@ -6271,9 +6146,11 @@ export default function planPiExtension(pi) {
|
|
|
6271
6146
|
pi.registerTool({
|
|
6272
6147
|
name: "planner-load",
|
|
6273
6148
|
label: "Planner Load",
|
|
6274
|
-
description: "Enable the planner for this project and start the web dashboard (LAN), then return a resume recap (project
|
|
6275
|
-
parameters: Type.Object({
|
|
6276
|
-
|
|
6149
|
+
description: "Enable the planner for this project and start the web dashboard (LAN), then return a human-facing resume recap (content[].text, Web UI address included) plus agent-only details.projectContext carrying the complete project-level context (description, goal, scope, out-of-scope, technologies, tools, language preferences, Project Guidelines, every Requirement, and every project Accepted Decision). This is the explicit way to enable the planner — it does NOT auto-start. Planner operations are NOT code edits. Never quote details.projectContext in the human-facing recap. If details.projectContext.contextComplete is false, the project-level context was NOT delivered this turn — follow details.projectContext.nextActions and retry before relying on it.",
|
|
6150
|
+
parameters: Type.Object({
|
|
6151
|
+
maxChars: Type.Optional(Type.Number({ description: `Single-response bound (chars) for the project-context delivery. Default ${DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS}. Raise this and retry if a prior call returned details.projectContext.contextComplete=false.` })),
|
|
6152
|
+
}),
|
|
6153
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
6277
6154
|
const st = await requirePlan(ctx);
|
|
6278
6155
|
if (!st)
|
|
6279
6156
|
return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
|
|
@@ -6305,7 +6182,28 @@ export default function planPiExtension(pi) {
|
|
|
6305
6182
|
}
|
|
6306
6183
|
if (preparation.changed)
|
|
6307
6184
|
recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
|
|
6308
|
-
|
|
6185
|
+
// Explicit planner load must deliver the complete project-level context
|
|
6186
|
+
// (P102(F005)/T404), attested through the same lossless contract used
|
|
6187
|
+
// everywhere else — shared with the /planner load command and MCP's
|
|
6188
|
+
// planner-load tool via buildProjectContextLoadReply.
|
|
6189
|
+
const boundedMaxChars = params.maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
|
|
6190
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
|
|
6191
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
|
|
6192
|
+
if (projectContextReply.structured.contextComplete) {
|
|
6193
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
6194
|
+
}
|
|
6195
|
+
// Pi's model reads only `content`; `details` is for the UI. The project
|
|
6196
|
+
// context used to live only in `details`, so it was attested as read
|
|
6197
|
+
// while the agent never saw it. Same builder as MCP and /planner load.
|
|
6198
|
+
// The guide is already in this turn's context (the per-turn plan
|
|
6199
|
+
// context block carries it), so only point at it here.
|
|
6200
|
+
const reply = buildPlannerLoadReply({
|
|
6201
|
+
recap,
|
|
6202
|
+
projectContext: projectContextReply,
|
|
6203
|
+
scopedDecisions: buildScopedDecisionContext(await st.loadAll()),
|
|
6204
|
+
plannerGuide: { mode: "pointer", path: ".planner/SKILL.md", customized: plannerSkill.customized, message: plannerSkill.message },
|
|
6205
|
+
});
|
|
6206
|
+
return { content: [{ type: "text", text: reply.text }], 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 }, ...reply.structured } };
|
|
6309
6207
|
},
|
|
6310
6208
|
});
|
|
6311
6209
|
pi.registerTool({
|
|
@@ -6485,11 +6383,11 @@ export default function planPiExtension(pi) {
|
|
|
6485
6383
|
"Operational rules:",
|
|
6486
6384
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
6487
6385
|
"- 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.",
|
|
6488
|
-
|
|
6386
|
+
`- ${ACCEPTED_DECISION_OWNERSHIP_RULE} Record it with accepted_decision_create on that single owner before treating the discussion as complete; decision_record no longer writes and only explains this.`,
|
|
6489
6387
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
6490
6388
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
6491
6389
|
"- 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).",
|
|
6492
|
-
"- If
|
|
6390
|
+
"- Nothing is ever blocked for lack of a task. If you are warned that you changed project code with no task in progress, start the task that covers it.",
|
|
6493
6391
|
...(extensionRules.length > 0
|
|
6494
6392
|
? [
|
|
6495
6393
|
"",
|