@agent-plan/pi-adapter 0.2.28 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +469 -551
- 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-DBJc9tGn.css +0 -1
- package/web-ui-dist/assets/index-NRDThOAB.js +0 -329
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, 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, 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,8 +144,6 @@ 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
|
// guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
|
|
@@ -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) {
|
|
@@ -387,46 +333,6 @@ async function getPlannerExecutionGuard(st) {
|
|
|
387
333
|
focusTaskTitle: focus?.task.title ?? "",
|
|
388
334
|
};
|
|
389
335
|
}
|
|
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
336
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
431
337
|
const [plan, resume, activity] = await Promise.all([
|
|
432
338
|
st.loadAll(),
|
|
@@ -684,59 +590,9 @@ function readUiConfig(ctx) {
|
|
|
684
590
|
},
|
|
685
591
|
};
|
|
686
592
|
}
|
|
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
593
|
function pad(n) {
|
|
696
594
|
return String(n).padStart(3, "0");
|
|
697
595
|
}
|
|
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
596
|
async function isPortAvailable(port) {
|
|
741
597
|
return await new Promise((resolve) => {
|
|
742
598
|
const probe = createServer();
|
|
@@ -918,7 +774,6 @@ function disablePlannerSession() {
|
|
|
918
774
|
plannerSessionEnabled = false;
|
|
919
775
|
startupResumePromptPending = false;
|
|
920
776
|
startupResumeSummaryPending = false;
|
|
921
|
-
startupResumeSummaryText = "";
|
|
922
777
|
if (startupResumeSummaryTimer)
|
|
923
778
|
clearTimeout(startupResumeSummaryTimer);
|
|
924
779
|
startupResumeSummaryTimer = null;
|
|
@@ -978,22 +833,11 @@ export default function planPiExtension(pi) {
|
|
|
978
833
|
},
|
|
979
834
|
}));
|
|
980
835
|
resetState();
|
|
981
|
-
autoHandoffTriggered = false;
|
|
982
|
-
previousContextPercent = 0;
|
|
983
836
|
const st = ensureStore(ctx);
|
|
984
837
|
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
838
|
const exists = await st.exists().catch(() => false);
|
|
996
|
-
|
|
839
|
+
if (exists)
|
|
840
|
+
await st.loadProject().catch(() => null);
|
|
997
841
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
998
842
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
999
843
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
@@ -1046,12 +890,11 @@ export default function planPiExtension(pi) {
|
|
|
1046
890
|
const st = ensureStore(ctx);
|
|
1047
891
|
if (await st.exists().catch(() => false)) {
|
|
1048
892
|
await writeProjectHandoff(st, "before compact").catch(() => { });
|
|
1049
|
-
autoHandoffTriggered = true;
|
|
1050
893
|
}
|
|
1051
894
|
}
|
|
1052
895
|
catch { }
|
|
1053
896
|
});
|
|
1054
|
-
pi.on("session_shutdown", async (event
|
|
897
|
+
pi.on("session_shutdown", async (event) => {
|
|
1055
898
|
try {
|
|
1056
899
|
const st = store;
|
|
1057
900
|
if (st && await st.exists().catch(() => false)) {
|
|
@@ -1153,7 +996,6 @@ export default function planPiExtension(pi) {
|
|
|
1153
996
|
});
|
|
1154
997
|
// Reset per-turn flags at the start of each turn.
|
|
1155
998
|
pi.on("turn_start", async () => {
|
|
1156
|
-
editedThisTurn = false;
|
|
1157
999
|
taskCompleteReminderSaidThisTurn = false;
|
|
1158
1000
|
});
|
|
1159
1001
|
// After edit/write succeeds, track activity and (once per turn, when a task
|
|
@@ -1163,7 +1005,6 @@ export default function planPiExtension(pi) {
|
|
|
1163
1005
|
return;
|
|
1164
1006
|
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
1165
1007
|
return;
|
|
1166
|
-
editedThisTurn = true;
|
|
1167
1008
|
if (taskCompleteReminderSaidThisTurn)
|
|
1168
1009
|
return;
|
|
1169
1010
|
try {
|
|
@@ -1453,34 +1294,6 @@ export default function planPiExtension(pi) {
|
|
|
1453
1294
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1454
1295
|
?? null;
|
|
1455
1296
|
}
|
|
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
1297
|
function profile_packageManager(pkg, lockfile) {
|
|
1485
1298
|
if (pkg?.packageManager)
|
|
1486
1299
|
return pkg.packageManager;
|
|
@@ -1630,20 +1443,18 @@ export default function planPiExtension(pi) {
|
|
|
1630
1443
|
await st.saveCodebaseProfile(profile);
|
|
1631
1444
|
await st.writeGenerated();
|
|
1632
1445
|
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
|
-
}
|
|
1446
|
+
sendToAgent("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
|
|
1447
|
+
"Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
|
|
1448
|
+
"Your mission is to produce a professional-grade specification. " +
|
|
1449
|
+
`\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
|
|
1450
|
+
"If these preferences are already set, follow them and do NOT ask again. " +
|
|
1451
|
+
"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" +
|
|
1452
|
+
"DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
|
|
1453
|
+
"1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
|
|
1454
|
+
"2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
|
|
1455
|
+
"3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
|
|
1456
|
+
"4. We will iterate until you have a complete, detailed picture.\n\n" +
|
|
1457
|
+
"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
1458
|
return;
|
|
1648
1459
|
}
|
|
1649
1460
|
if (b === "language") {
|
|
@@ -1761,10 +1572,7 @@ export default function planPiExtension(pi) {
|
|
|
1761
1572
|
if (!targetType)
|
|
1762
1573
|
return;
|
|
1763
1574
|
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 {
|
|
1575
|
+
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
1576
|
ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
|
|
1769
1577
|
}
|
|
1770
1578
|
return;
|
|
@@ -1983,26 +1791,13 @@ export default function planPiExtension(pi) {
|
|
|
1983
1791
|
ctx.ui.notify("Aborted", "warning");
|
|
1984
1792
|
return;
|
|
1985
1793
|
}
|
|
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
|
-
}
|
|
1794
|
+
const outcome = await deleteFeatureCascade(st, feature.id, { cascade });
|
|
1795
|
+
if (!outcome.ok) {
|
|
1796
|
+
ctx.ui.notify(`Feature delete failed: ${outcome.error}`, "error");
|
|
1797
|
+
return;
|
|
2003
1798
|
}
|
|
2004
|
-
|
|
2005
|
-
ctx.ui.notify(`Feature deleted: ${
|
|
1799
|
+
const { result } = outcome;
|
|
1800
|
+
ctx.ui.notify(`Feature deleted: ${result.ref}${result.phaseCount > 0 ? (result.cascade ? `; deleted ${result.phaseCount} phase(s)` : `; unlinked ${result.phaseCount} phase(s)`) : ""}`, "info");
|
|
2006
1801
|
return;
|
|
2007
1802
|
}
|
|
2008
1803
|
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
@@ -2038,8 +1833,6 @@ export default function planPiExtension(pi) {
|
|
|
2038
1833
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
2039
1834
|
let phase;
|
|
2040
1835
|
await st.runBatch(() => withFeatureLock(feature.id, async () => {
|
|
2041
|
-
const phases = await st.loadAllPhases();
|
|
2042
|
-
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
2043
1836
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2044
1837
|
const id = createPhaseId();
|
|
2045
1838
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
@@ -2119,11 +1912,9 @@ export default function planPiExtension(pi) {
|
|
|
2119
1912
|
notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
|
|
2120
1913
|
updatedAt: nowISO(),
|
|
2121
1914
|
}));
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
"Do NOT invoke GSD. Stay in Agent Plan.");
|
|
2126
|
-
}
|
|
1915
|
+
sendToAgent(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
|
|
1916
|
+
"Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
|
|
1917
|
+
"Do NOT invoke GSD. Stay in Agent Plan.");
|
|
2127
1918
|
}
|
|
2128
1919
|
const answers = {};
|
|
2129
1920
|
const questions = [
|
|
@@ -2258,7 +2049,7 @@ export default function planPiExtension(pi) {
|
|
|
2258
2049
|
// ═══════════════════════════════════════════════════════════════
|
|
2259
2050
|
if (a === "task") {
|
|
2260
2051
|
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");
|
|
2052
|
+
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
2053
|
return;
|
|
2263
2054
|
}
|
|
2264
2055
|
if (b === "add") {
|
|
@@ -2304,7 +2095,7 @@ export default function planPiExtension(pi) {
|
|
|
2304
2095
|
updatedAt: nowISO(),
|
|
2305
2096
|
}));
|
|
2306
2097
|
await st.writeGenerated();
|
|
2307
|
-
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
2098
|
+
ctx.ui.notify(`Task created: ${taskId} — ${title} (${taskCreatedPriorityFragment(priority)})`, "info");
|
|
2308
2099
|
if (isYes(startDiscuss)) {
|
|
2309
2100
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
2310
2101
|
}
|
|
@@ -2333,22 +2124,24 @@ export default function planPiExtension(pi) {
|
|
|
2333
2124
|
const { phase, task } = resolved;
|
|
2334
2125
|
const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
|
|
2335
2126
|
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
|
-
}));
|
|
2127
|
+
const checklistTitles = checklistSeed?.trim() ? splitCsv(checklistSeed) : undefined;
|
|
2128
|
+
let checklistLostTicks = [];
|
|
2129
|
+
await st.updateTask(phase.id, task.id, (current) => {
|
|
2130
|
+
const next = { ...current, updatedAt: nowISO() };
|
|
2131
|
+
if (description?.trim())
|
|
2132
|
+
next.description = description.trim();
|
|
2133
|
+
if (checklistTitles) {
|
|
2134
|
+
const replacement = replaceChecklist(current.checklist ?? [], checklistTitles, task.id);
|
|
2135
|
+
next.checklist = replacement.items;
|
|
2136
|
+
checklistLostTicks = replacement.lostTicks;
|
|
2137
|
+
}
|
|
2138
|
+
return next;
|
|
2139
|
+
});
|
|
2350
2140
|
await st.writeGenerated();
|
|
2351
|
-
|
|
2141
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
2142
|
+
? ` Warning: lost tick on ${checklistLostTicks.length} item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
2143
|
+
: "";
|
|
2144
|
+
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
2145
|
return;
|
|
2353
2146
|
}
|
|
2354
2147
|
if (b === "show") {
|
|
@@ -2480,6 +2273,35 @@ export default function planPiExtension(pi) {
|
|
|
2480
2273
|
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
2274
|
return;
|
|
2482
2275
|
}
|
|
2276
|
+
if (b === "recommend") {
|
|
2277
|
+
// "What should I work on next" is a supervisor question, not only
|
|
2278
|
+
// an agent one (see P104(F005)/T419) — the only reason it lacked an
|
|
2279
|
+
// interactive path was that the other agent-only commands (pause,
|
|
2280
|
+
// switch, deviation) set the pattern by default. Reuses the same
|
|
2281
|
+
// recommendNextWork + buildRecommendationReply pipeline as the
|
|
2282
|
+
// task_recommend tool, so a person at the command line and an agent
|
|
2283
|
+
// calling the tool see identical reasoning, just a compact few-line
|
|
2284
|
+
// notify instead of a structured payload.
|
|
2285
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
2286
|
+
st.loadFeatures(),
|
|
2287
|
+
st.loadAllPhases(),
|
|
2288
|
+
st.loadProject(),
|
|
2289
|
+
st.loadResume(),
|
|
2290
|
+
st.listHandoffs(),
|
|
2291
|
+
st.listArchivedHandoffs(),
|
|
2292
|
+
]);
|
|
2293
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
2294
|
+
handoffs: handoffs.map((handoff) => ({
|
|
2295
|
+
compositeRef: handoff.compositeRef,
|
|
2296
|
+
firstLine: handoff.firstLine,
|
|
2297
|
+
resumeReady: handoff.resumeReady,
|
|
2298
|
+
})),
|
|
2299
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
2300
|
+
});
|
|
2301
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
2302
|
+
ctx.ui.notify(reply.text, "info");
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2483
2305
|
if (b === "start") {
|
|
2484
2306
|
return st.runBatch(async () => {
|
|
2485
2307
|
let phase = null;
|
|
@@ -2765,11 +2587,7 @@ export default function planPiExtension(pi) {
|
|
|
2765
2587
|
return;
|
|
2766
2588
|
}
|
|
2767
2589
|
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" +
|
|
2590
|
+
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
2591
|
"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
2592
|
"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
2593
|
"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 +2601,9 @@ export default function planPiExtension(pi) {
|
|
|
2783
2601
|
"- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
|
|
2784
2602
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2785
2603
|
"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(
|
|
2604
|
+
ctx.ui.notify(handoffPrepared
|
|
2605
|
+
? "Instructing the agent to prepare the phase handoff…"
|
|
2606
|
+
: "Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", handoffPrepared ? "info" : "warning");
|
|
2787
2607
|
return;
|
|
2788
2608
|
}
|
|
2789
2609
|
if (action === "clear" || action === "delete") {
|
|
@@ -2946,6 +2766,7 @@ export default function planPiExtension(pi) {
|
|
|
2946
2766
|
// core buildRecap so Pi and Claude Code/Codex present identical content.
|
|
2947
2767
|
let recapText = "";
|
|
2948
2768
|
let plannerSkillContext = "";
|
|
2769
|
+
let projectContextBlock = "";
|
|
2949
2770
|
try {
|
|
2950
2771
|
const plannerSkill = await st.syncPlannerSkill();
|
|
2951
2772
|
await st.syncGrillMeSkill();
|
|
@@ -2953,6 +2774,20 @@ export default function planPiExtension(pi) {
|
|
|
2953
2774
|
recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2954
2775
|
if (preparation.changed)
|
|
2955
2776
|
recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
|
|
2777
|
+
// Explicit planner load must deliver the complete project-level
|
|
2778
|
+
// context (P102(F005)/T404), attested through the same lossless
|
|
2779
|
+
// contract used everywhere else. buildProjectContextLoadReply is the
|
|
2780
|
+
// single shared shaping for this reply — the planner-load tool below
|
|
2781
|
+
// uses the identical builder. Like plannerSkillContext, this is
|
|
2782
|
+
// agent-only: it must never be quoted into the human-facing recap
|
|
2783
|
+
// (see the "must not leak into the human recap" invariant covered by
|
|
2784
|
+
// host-registration.test.mjs for the planner-load tool).
|
|
2785
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
|
|
2786
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
|
|
2787
|
+
if (projectContextReply.structured.contextComplete) {
|
|
2788
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
2789
|
+
}
|
|
2790
|
+
projectContextBlock = `[agent-only project context; do not quote in the recap]\n${projectContextReply.text}\n[end agent-only project context]`;
|
|
2956
2791
|
plannerSkillContext = `[agent-only planner usage skill; do not quote in the recap]\n${plannerSkill.customized ? `${plannerSkill.message}\n` : ""}${plannerSkill.content}\n[end agent-only planner usage skill]`;
|
|
2957
2792
|
}
|
|
2958
2793
|
catch (e) {
|
|
@@ -2960,7 +2795,7 @@ export default function planPiExtension(pi) {
|
|
|
2960
2795
|
}
|
|
2961
2796
|
pi.sendMessage({
|
|
2962
2797
|
customType: "planner-resume-trigger",
|
|
2963
|
-
content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill, then present the planner startup recap
|
|
2798
|
+
content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill and project context below, then present the planner startup recap to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n" + plannerSkillContext + "\n\n" + projectContextBlock + "\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
|
|
2964
2799
|
display: false,
|
|
2965
2800
|
}, {
|
|
2966
2801
|
triggerTurn: true,
|
|
@@ -3057,7 +2892,7 @@ export default function planPiExtension(pi) {
|
|
|
3057
2892
|
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
2893
|
parameters: Type.Object({
|
|
3059
2894
|
projectName: Type.String({ description: "Concise project name/title" }),
|
|
3060
|
-
description: Type.Optional(Type.String({ description:
|
|
2895
|
+
description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3061
2896
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
3062
2897
|
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
2898
|
}),
|
|
@@ -3086,7 +2921,7 @@ export default function planPiExtension(pi) {
|
|
|
3086
2921
|
if (requirementTitles.length > 0) {
|
|
3087
2922
|
const now = nowISO();
|
|
3088
2923
|
await st.saveRequirements({
|
|
3089
|
-
requirements: requirementTitles.map((title
|
|
2924
|
+
requirements: requirementTitles.map((title) => ({
|
|
3090
2925
|
id: createRequirementId(),
|
|
3091
2926
|
title,
|
|
3092
2927
|
description: "",
|
|
@@ -3111,7 +2946,7 @@ export default function planPiExtension(pi) {
|
|
|
3111
2946
|
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
2947
|
parameters: Type.Object({
|
|
3113
2948
|
name: Type.Optional(Type.String({ description: "Project title" })),
|
|
3114
|
-
description: Type.Optional(Type.String({ description:
|
|
2949
|
+
description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3115
2950
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
|
|
3116
2951
|
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
3117
2952
|
scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
|
|
@@ -3119,17 +2954,24 @@ export default function planPiExtension(pi) {
|
|
|
3119
2954
|
technologies: Type.Optional(Type.Array(Type.String(), { description: "Replace technologies list" })),
|
|
3120
2955
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Replace tools list" })),
|
|
3121
2956
|
globalRules: Type.Optional(Type.Array(Type.String(), { description: "Replace global rules" })),
|
|
3122
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
2957
|
+
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
2958
|
}),
|
|
3124
2959
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3125
2960
|
const st = await requirePlan(ctx);
|
|
3126
2961
|
if (!st)
|
|
3127
2962
|
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
2963
|
+
if (params.decisions !== undefined) {
|
|
2964
|
+
return {
|
|
2965
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
2966
|
+
details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" },
|
|
2967
|
+
isError: true,
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
3128
2970
|
const project = await st.loadProject();
|
|
3129
|
-
const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"
|
|
2971
|
+
const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
|
|
3130
2972
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3131
2973
|
if (receivedFields.length === 0) {
|
|
3132
|
-
return
|
|
2974
|
+
return piMutationNoFieldsFailure({
|
|
3133
2975
|
entity: "project",
|
|
3134
2976
|
ref: project.name,
|
|
3135
2977
|
operation: "update",
|
|
@@ -3166,8 +3008,6 @@ export default function planPiExtension(pi) {
|
|
|
3166
3008
|
project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
|
|
3167
3009
|
if (params.globalRules !== undefined)
|
|
3168
3010
|
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
3011
|
const persisted = await st.updateProject((current) => {
|
|
3172
3012
|
const next = {
|
|
3173
3013
|
...current,
|
|
@@ -3179,7 +3019,6 @@ export default function planPiExtension(pi) {
|
|
|
3179
3019
|
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3180
3020
|
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
3181
3021
|
...(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
3022
|
};
|
|
3184
3023
|
if (params.descriptionRef !== undefined) {
|
|
3185
3024
|
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
@@ -3191,7 +3030,14 @@ export default function planPiExtension(pi) {
|
|
|
3191
3030
|
return next;
|
|
3192
3031
|
});
|
|
3193
3032
|
await st.writeGenerated();
|
|
3194
|
-
|
|
3033
|
+
const projectUpdateReply = buildMutationReply({
|
|
3034
|
+
identity: { entity: "project", ref: persisted.name, title: persisted.name },
|
|
3035
|
+
operation: "update",
|
|
3036
|
+
updatedFields: receivedFields,
|
|
3037
|
+
changedValues: persisted,
|
|
3038
|
+
readBackCommand: "plan_get",
|
|
3039
|
+
});
|
|
3040
|
+
return { content: [{ type: "text", text: projectUpdateReply.text }], details: projectUpdateReply.structured };
|
|
3195
3041
|
},
|
|
3196
3042
|
});
|
|
3197
3043
|
pi.registerTool({
|
|
@@ -3333,7 +3179,7 @@ export default function planPiExtension(pi) {
|
|
|
3333
3179
|
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
3180
|
parameters: Type.Object({
|
|
3335
3181
|
title: Type.String({ description: "Requirement title" }),
|
|
3336
|
-
description: Type.Optional(Type.String({ description:
|
|
3182
|
+
description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3337
3183
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
|
|
3338
3184
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3339
3185
|
title: Type.String({ description: "Macro-task title" }),
|
|
@@ -3392,7 +3238,7 @@ export default function planPiExtension(pi) {
|
|
|
3392
3238
|
parameters: Type.Object({
|
|
3393
3239
|
requirementId: Type.String({ description: "Requirement ID" }),
|
|
3394
3240
|
title: Type.Optional(Type.String({ description: "Requirement title" })),
|
|
3395
|
-
description: Type.Optional(Type.String({ description:
|
|
3241
|
+
description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
3396
3242
|
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
|
|
3397
3243
|
macroTasks: Type.Optional(Type.Array(Type.Object({
|
|
3398
3244
|
id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
|
|
@@ -3413,7 +3259,7 @@ export default function planPiExtension(pi) {
|
|
|
3413
3259
|
return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
|
|
3414
3260
|
}
|
|
3415
3261
|
if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
|
|
3416
|
-
return
|
|
3262
|
+
return piMutationNoFieldsFailure({
|
|
3417
3263
|
entity: "requirement",
|
|
3418
3264
|
ref: params.requirementId,
|
|
3419
3265
|
operation: "update",
|
|
@@ -3421,6 +3267,12 @@ export default function planPiExtension(pi) {
|
|
|
3421
3267
|
retryCommand: `requirement_update ${params.requirementId}`,
|
|
3422
3268
|
});
|
|
3423
3269
|
}
|
|
3270
|
+
const receivedFields = [
|
|
3271
|
+
...(params.title !== undefined ? ["title"] : []),
|
|
3272
|
+
...(params.description !== undefined ? ["description"] : []),
|
|
3273
|
+
...(params.linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
|
|
3274
|
+
...(params.macroTasks !== undefined ? ["macroTasks"] : []),
|
|
3275
|
+
];
|
|
3424
3276
|
const links = params.linkedPhaseIds === undefined
|
|
3425
3277
|
? undefined
|
|
3426
3278
|
: await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
@@ -3448,7 +3300,14 @@ export default function planPiExtension(pi) {
|
|
|
3448
3300
|
return { content: [{ type: "text", text: message }], details: {} };
|
|
3449
3301
|
}
|
|
3450
3302
|
await st.writeGenerated();
|
|
3451
|
-
|
|
3303
|
+
const requirementUpdateReply = buildMutationReply({
|
|
3304
|
+
identity: { entity: "requirement", ref: persisted.id, id: persisted.id, title: persisted.title },
|
|
3305
|
+
operation: "update",
|
|
3306
|
+
updatedFields: receivedFields,
|
|
3307
|
+
changedValues: persisted,
|
|
3308
|
+
readBackCommand: "requirement_list",
|
|
3309
|
+
});
|
|
3310
|
+
return { content: [{ type: "text", text: requirementUpdateReply.text }], details: requirementUpdateReply.structured };
|
|
3452
3311
|
},
|
|
3453
3312
|
});
|
|
3454
3313
|
pi.registerTool({
|
|
@@ -3495,7 +3354,7 @@ export default function planPiExtension(pi) {
|
|
|
3495
3354
|
async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
|
|
3496
3355
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
|
|
3497
3356
|
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
|
|
3357
|
+
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
3358
|
});
|
|
3500
3359
|
pi.registerTool({
|
|
3501
3360
|
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 +3427,20 @@ export default function planPiExtension(pi) {
|
|
|
3568
3427
|
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
3569
3428
|
: `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
|
|
3570
3429
|
const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
|
|
3430
|
+
// P104(F005)/T422: a phase can be left in-progress with no active task
|
|
3431
|
+
// and nothing else said so on this cheap read — render that fact,
|
|
3432
|
+
// priority-ordered, the same shape planner-show renders on the MCP side.
|
|
3433
|
+
const openPhaseWork = listOpenPhaseWork(plan.phases, plan.features.features);
|
|
3434
|
+
const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
|
|
3571
3435
|
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: {
|
|
3436
|
+
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}` }],
|
|
3437
|
+
details: {
|
|
3438
|
+
...plan,
|
|
3439
|
+
activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict",
|
|
3440
|
+
activeTasks,
|
|
3441
|
+
openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
|
|
3442
|
+
openPhaseWorkTotal: openPhaseWork.length,
|
|
3443
|
+
},
|
|
3574
3444
|
};
|
|
3575
3445
|
},
|
|
3576
3446
|
});
|
|
@@ -3798,53 +3668,27 @@ export default function planPiExtension(pi) {
|
|
|
3798
3668
|
if (!r.ok)
|
|
3799
3669
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3800
3670
|
const phase = await st.loadPhase(r.phase.id);
|
|
3801
|
-
|
|
3802
|
-
if (!bounded.content.trim()) {
|
|
3671
|
+
if (!phase.handoff.trim()) {
|
|
3803
3672
|
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
3804
3673
|
&& 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 } };
|
|
3674
|
+
const reply = archived
|
|
3675
|
+
? buildHandoffShowReply({
|
|
3676
|
+
kind: "archived",
|
|
3677
|
+
phaseRef: r.compositeRef,
|
|
3678
|
+
phaseId: phase.id,
|
|
3679
|
+
content: archived.content,
|
|
3680
|
+
archiveReason: archived.reason,
|
|
3681
|
+
archivedAt: archived.archivedAt,
|
|
3682
|
+
archiveFile: archived.file,
|
|
3683
|
+
handoffAudit: phase.handoffAudit,
|
|
3684
|
+
})
|
|
3685
|
+
: buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
|
|
3686
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3833
3687
|
}
|
|
3834
3688
|
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
3835
3689
|
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
|
-
};
|
|
3690
|
+
const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
|
|
3691
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3848
3692
|
},
|
|
3849
3693
|
});
|
|
3850
3694
|
pi.registerTool({
|
|
@@ -3881,7 +3725,7 @@ export default function planPiExtension(pi) {
|
|
|
3881
3725
|
}
|
|
3882
3726
|
catch (error) {
|
|
3883
3727
|
if (error instanceof HandoffContractError)
|
|
3884
|
-
return
|
|
3728
|
+
return piHandoffContractFailure(error);
|
|
3885
3729
|
const message = error instanceof Error ? error.message : String(error);
|
|
3886
3730
|
return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
|
|
3887
3731
|
}
|
|
@@ -3891,7 +3735,13 @@ export default function planPiExtension(pi) {
|
|
|
3891
3735
|
name: "handoff_prepare",
|
|
3892
3736
|
label: "Handoff Prepare",
|
|
3893
3737
|
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({
|
|
3738
|
+
parameters: Type.Object({
|
|
3739
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3740
|
+
supportingDocuments: Type.Optional(Type.Array(Type.Object({
|
|
3741
|
+
path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
|
|
3742
|
+
description: Type.String({ description: "What the document contains and why the next agent needs it." }),
|
|
3743
|
+
}), { description: "Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically." })),
|
|
3744
|
+
}),
|
|
3895
3745
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3896
3746
|
const st = await requirePlan(ctx);
|
|
3897
3747
|
if (!st)
|
|
@@ -3900,42 +3750,13 @@ export default function planPiExtension(pi) {
|
|
|
3900
3750
|
if (!r.ok)
|
|
3901
3751
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3902
3752
|
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
|
-
};
|
|
3753
|
+
const audit = await st.preparePhaseHandoff(r.phase.id, params.supportingDocuments);
|
|
3754
|
+
const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
|
|
3755
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
3937
3756
|
}
|
|
3938
3757
|
catch (error) {
|
|
3758
|
+
if (error instanceof HandoffContractError)
|
|
3759
|
+
return piHandoffContractFailure(error, "preparation");
|
|
3939
3760
|
const message = error instanceof Error ? error.message : String(error);
|
|
3940
3761
|
return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
|
|
3941
3762
|
}
|
|
@@ -3944,16 +3765,20 @@ export default function planPiExtension(pi) {
|
|
|
3944
3765
|
pi.registerTool({
|
|
3945
3766
|
name: "handoff_write",
|
|
3946
3767
|
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.",
|
|
3768
|
+
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
3769
|
parameters: Type.Object({
|
|
3949
3770
|
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)." })),
|
|
3771
|
+
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
3772
|
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
3773
|
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." })),
|
|
3774
|
+
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." })),
|
|
3775
|
+
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
3776
|
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3956
3777
|
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3778
|
+
sectionDispositions: Type.Optional(Type.Array(Type.Object({
|
|
3779
|
+
section: Type.String({ description: "Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal." }),
|
|
3780
|
+
disposition: Type.String({ description: "What happened to that section: dropped, superseded, or folded into another section." }),
|
|
3781
|
+
}), { 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
3782
|
completenessAudit: Type.Optional(Type.Object({
|
|
3958
3783
|
version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
3959
3784
|
entries: Type.Array(Type.Object({
|
|
@@ -3999,24 +3824,29 @@ export default function planPiExtension(pi) {
|
|
|
3999
3824
|
const st = await requirePlan(ctx);
|
|
4000
3825
|
if (!st)
|
|
4001
3826
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
3827
|
+
// content/markdown_content are optional so a retry after a failed write
|
|
3828
|
+
// can omit them and reuse the body PlanStore retained against this
|
|
3829
|
+
// phase + expectedHandoffUpdatedAt (see PlanStore.refreshPhaseHandoff).
|
|
3830
|
+
// body stays undefined only in that case; the title/heading checks
|
|
3831
|
+
// below apply only to freshly-submitted text.
|
|
3832
|
+
let body = (params.markdown_content ?? params.content ?? "").trim() || undefined;
|
|
4005
3833
|
const title = params.title?.trim();
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
lines[firstIdx]
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
3834
|
+
if (body !== undefined) {
|
|
3835
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
3836
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3837
|
+
const effectiveHeadingText = title || firstHeadingText;
|
|
3838
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
3839
|
+
return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
|
|
3840
|
+
}
|
|
3841
|
+
if (title) {
|
|
3842
|
+
const lines = body.split(/\r?\n/);
|
|
3843
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
3844
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3845
|
+
lines[firstIdx] = `# ${title}`;
|
|
3846
|
+
else
|
|
3847
|
+
lines.unshift(`# ${title}`, "");
|
|
3848
|
+
body = lines.join("\n");
|
|
3849
|
+
}
|
|
4020
3850
|
}
|
|
4021
3851
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
4022
3852
|
if (!r.ok)
|
|
@@ -4046,9 +3876,10 @@ export default function planPiExtension(pi) {
|
|
|
4046
3876
|
try {
|
|
4047
3877
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
4048
3878
|
reason: params.reason.trim(),
|
|
4049
|
-
content: body,
|
|
3879
|
+
...(body !== undefined ? { content: body } : {}),
|
|
4050
3880
|
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
4051
3881
|
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3882
|
+
...(params.sectionDispositions ? { sectionDispositions: params.sectionDispositions } : {}),
|
|
4052
3883
|
...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
|
|
4053
3884
|
...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
|
|
4054
3885
|
...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
|
|
@@ -4085,9 +3916,9 @@ export default function planPiExtension(pi) {
|
|
|
4085
3916
|
}
|
|
4086
3917
|
catch (error) {
|
|
4087
3918
|
if (error instanceof HandoffContractError)
|
|
4088
|
-
return
|
|
3919
|
+
return piHandoffContractFailure(error);
|
|
4089
3920
|
const message = error instanceof Error ? error.message : String(error);
|
|
4090
|
-
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}
|
|
3921
|
+
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
3922
|
}
|
|
4092
3923
|
},
|
|
4093
3924
|
});
|
|
@@ -4219,7 +4050,6 @@ export default function planPiExtension(pi) {
|
|
|
4219
4050
|
return st.runBatch(async () => {
|
|
4220
4051
|
const now = nowISO();
|
|
4221
4052
|
const status = params.status ?? "planned";
|
|
4222
|
-
const currentFeatures = (await st.loadFeatures()).features;
|
|
4223
4053
|
const id = createFeatureId();
|
|
4224
4054
|
const identity = await st.allocateEntityIdentity("feature", id);
|
|
4225
4055
|
const priority = await st.nextPriority("feature");
|
|
@@ -4264,7 +4094,7 @@ export default function planPiExtension(pi) {
|
|
|
4264
4094
|
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
4265
4095
|
parameters: Type.Object({
|
|
4266
4096
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4267
|
-
description: Type.Optional(Type.String({ description:
|
|
4097
|
+
description: Type.Optional(Type.String({ description: `Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4268
4098
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4269
4099
|
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
4270
4100
|
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
@@ -4282,7 +4112,7 @@ export default function planPiExtension(pi) {
|
|
|
4282
4112
|
const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
|
|
4283
4113
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4284
4114
|
if (receivedFields.length === 0) {
|
|
4285
|
-
return
|
|
4115
|
+
return piMutationNoFieldsFailure({
|
|
4286
4116
|
entity: "feature",
|
|
4287
4117
|
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4288
4118
|
operation: "discuss",
|
|
@@ -4326,7 +4156,14 @@ export default function planPiExtension(pi) {
|
|
|
4326
4156
|
if (!feature)
|
|
4327
4157
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
4328
4158
|
await st.writeGenerated();
|
|
4329
|
-
|
|
4159
|
+
const featureDiscussReply = buildMutationReply({
|
|
4160
|
+
identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
|
|
4161
|
+
operation: "discuss",
|
|
4162
|
+
updatedFields: receivedFields,
|
|
4163
|
+
changedValues: feature,
|
|
4164
|
+
readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
|
|
4165
|
+
});
|
|
4166
|
+
return { content: [{ type: "text", text: featureDiscussReply.text }], details: featureDiscussReply.structured };
|
|
4330
4167
|
},
|
|
4331
4168
|
});
|
|
4332
4169
|
pi.registerTool({
|
|
@@ -4336,7 +4173,7 @@ export default function planPiExtension(pi) {
|
|
|
4336
4173
|
parameters: Type.Object({
|
|
4337
4174
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
4338
4175
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
4339
|
-
description: Type.Optional(Type.String({ description:
|
|
4176
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4340
4177
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
|
|
4341
4178
|
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
4179
|
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
@@ -4363,12 +4200,12 @@ export default function planPiExtension(pi) {
|
|
|
4363
4200
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
4364
4201
|
const featureId = resolvedFeature.feature.id;
|
|
4365
4202
|
if (params.status !== undefined) {
|
|
4366
|
-
return
|
|
4203
|
+
return piDerivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
4367
4204
|
}
|
|
4368
4205
|
if (params.acceptedDecisions !== undefined) {
|
|
4369
4206
|
return {
|
|
4370
|
-
content: [{ type: "text", text:
|
|
4371
|
-
details: { updated: false, errorCode:
|
|
4207
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
4208
|
+
details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
|
|
4372
4209
|
isError: true,
|
|
4373
4210
|
};
|
|
4374
4211
|
}
|
|
@@ -4384,7 +4221,7 @@ export default function planPiExtension(pi) {
|
|
|
4384
4221
|
];
|
|
4385
4222
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4386
4223
|
if (receivedFields.length === 0) {
|
|
4387
|
-
return
|
|
4224
|
+
return piMutationNoFieldsFailure({
|
|
4388
4225
|
entity: "feature",
|
|
4389
4226
|
ref: formatFeatureRef(resolvedFeature.feature.number),
|
|
4390
4227
|
operation: "update",
|
|
@@ -4442,7 +4279,13 @@ export default function planPiExtension(pi) {
|
|
|
4442
4279
|
type: "text",
|
|
4443
4280
|
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
4444
4281
|
}],
|
|
4445
|
-
details: {
|
|
4282
|
+
details: buildMutationReply({
|
|
4283
|
+
identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
|
|
4284
|
+
operation: "update",
|
|
4285
|
+
updatedFields: receivedFields,
|
|
4286
|
+
changedValues: feature,
|
|
4287
|
+
readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
|
|
4288
|
+
}).structured,
|
|
4446
4289
|
};
|
|
4447
4290
|
},
|
|
4448
4291
|
});
|
|
@@ -4462,27 +4305,15 @@ export default function planPiExtension(pi) {
|
|
|
4462
4305
|
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
4463
4306
|
if (!resolvedFeature.ok)
|
|
4464
4307
|
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 } };
|
|
4308
|
+
const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(params.cascade) });
|
|
4309
|
+
if (!outcome.ok) {
|
|
4310
|
+
return { content: [{ type: "text", text: `Feature delete failed: ${outcome.error}` }], details: { deleted: false, errorCode: outcome.errorCode }, isError: true };
|
|
4311
|
+
}
|
|
4312
|
+
const { result } = outcome;
|
|
4313
|
+
return {
|
|
4314
|
+
content: [{ type: "text", text: `Feature deleted: ${result.ref}${result.cascade ? ` (cascade: ${result.phaseCount} phases)` : result.phaseCount > 0 ? ` (unlinked: ${result.phaseCount} phases)` : ""}` }],
|
|
4315
|
+
details: { deleted: true, ref: result.ref, id: result.id, cascadedPhases: result.cascade ? result.phaseCount : 0, unlinkedPhases: result.cascade ? 0 : result.phaseCount },
|
|
4316
|
+
};
|
|
4486
4317
|
},
|
|
4487
4318
|
});
|
|
4488
4319
|
// ── phases ──────────────────────────────────────────────────────────
|
|
@@ -4660,7 +4491,7 @@ export default function planPiExtension(pi) {
|
|
|
4660
4491
|
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4661
4492
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4662
4493
|
if (receivedFields.length === 0) {
|
|
4663
|
-
return
|
|
4494
|
+
return piMutationNoFieldsFailure({
|
|
4664
4495
|
entity: "phase",
|
|
4665
4496
|
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4666
4497
|
operation: "discuss",
|
|
@@ -4709,7 +4540,15 @@ export default function planPiExtension(pi) {
|
|
|
4709
4540
|
});
|
|
4710
4541
|
await st.writeGenerated();
|
|
4711
4542
|
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
4712
|
-
|
|
4543
|
+
const phaseDiscussReply = buildMutationReply({
|
|
4544
|
+
identity: { entity: "phase", ref: phaseRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
4545
|
+
operation: "discuss",
|
|
4546
|
+
updatedFields: receivedFields,
|
|
4547
|
+
changedValues: phase,
|
|
4548
|
+
extras: { contextReady: true },
|
|
4549
|
+
readBackCommand: `phase_get ${phaseRef}`,
|
|
4550
|
+
});
|
|
4551
|
+
return { content: [{ type: "text", text: phaseDiscussReply.text }], details: phaseDiscussReply.structured };
|
|
4713
4552
|
},
|
|
4714
4553
|
});
|
|
4715
4554
|
pi.registerTool({
|
|
@@ -4721,7 +4560,7 @@ export default function planPiExtension(pi) {
|
|
|
4721
4560
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
4722
4561
|
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
4562
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
4724
|
-
description: Type.Optional(Type.String({ description:
|
|
4563
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
4725
4564
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
|
|
4726
4565
|
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
4727
4566
|
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
@@ -4730,7 +4569,7 @@ export default function planPiExtension(pi) {
|
|
|
4730
4569
|
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
|
|
4731
4570
|
risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
|
|
4732
4571
|
openQuestions: Type.Optional(Type.Array(Type.String(), { description: "Replace open questions list" })),
|
|
4733
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
4572
|
+
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
4573
|
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
4735
4574
|
id: Type.String(),
|
|
4736
4575
|
title: Type.String(),
|
|
@@ -4758,19 +4597,26 @@ export default function planPiExtension(pi) {
|
|
|
4758
4597
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
4759
4598
|
}
|
|
4760
4599
|
if (params.status !== undefined) {
|
|
4761
|
-
return
|
|
4600
|
+
return piDerivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
4762
4601
|
}
|
|
4763
4602
|
if (params.acceptedDecisions !== undefined) {
|
|
4764
4603
|
return {
|
|
4765
|
-
content: [{ type: "text", text:
|
|
4766
|
-
details: { updated: false, errorCode:
|
|
4604
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
4605
|
+
details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4767
4606
|
isError: true,
|
|
4768
4607
|
};
|
|
4769
4608
|
}
|
|
4770
|
-
|
|
4609
|
+
if (params.decisions !== undefined) {
|
|
4610
|
+
return {
|
|
4611
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
4612
|
+
details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
|
|
4613
|
+
isError: true,
|
|
4614
|
+
};
|
|
4615
|
+
}
|
|
4616
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
4771
4617
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
4772
4618
|
if (receivedFields.length === 0) {
|
|
4773
|
-
return
|
|
4619
|
+
return piMutationNoFieldsFailure({
|
|
4774
4620
|
entity: "phase",
|
|
4775
4621
|
ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
|
|
4776
4622
|
operation: "update",
|
|
@@ -4812,7 +4658,6 @@ export default function planPiExtension(pi) {
|
|
|
4812
4658
|
...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4813
4659
|
...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4814
4660
|
...(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
4661
|
...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
|
|
4817
4662
|
...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
|
|
4818
4663
|
updatedAt: nowISO(),
|
|
@@ -4847,16 +4692,22 @@ export default function planPiExtension(pi) {
|
|
|
4847
4692
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
4848
4693
|
.filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
|
|
4849
4694
|
.map((entry) => entry.ownerRef) ?? [];
|
|
4850
|
-
const
|
|
4851
|
-
|
|
4852
|
-
: ""
|
|
4853
|
-
|
|
4695
|
+
const phaseUpdateRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures));
|
|
4696
|
+
const phaseUpdateReply = buildMutationReply({
|
|
4697
|
+
identity: { entity: "phase", ref: phaseUpdateRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
4698
|
+
operation: "update",
|
|
4699
|
+
updatedFields: receivedFields,
|
|
4700
|
+
changedValues: phase,
|
|
4701
|
+
extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
|
|
4702
|
+
readBackCommand: `phase_get ${phaseUpdateRef}`,
|
|
4703
|
+
});
|
|
4704
|
+
return { content: [{ type: "text", text: phaseUpdateReply.text }], details: phaseUpdateReply.structured };
|
|
4854
4705
|
},
|
|
4855
4706
|
});
|
|
4856
4707
|
pi.registerTool({
|
|
4857
4708
|
name: "decision_record",
|
|
4858
4709
|
label: "Decision Record",
|
|
4859
|
-
description: "
|
|
4710
|
+
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.",
|
|
4860
4711
|
parameters: Type.Object({
|
|
4861
4712
|
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
4862
4713
|
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
@@ -4879,49 +4730,17 @@ export default function planPiExtension(pi) {
|
|
|
4879
4730
|
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
4880
4731
|
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
4881
4732
|
}
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
try {
|
|
4894
|
-
await st.updateFeatures((doc) => {
|
|
4895
|
-
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
4896
|
-
if (!feature)
|
|
4897
|
-
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
4898
|
-
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
4899
|
-
feature.updatedAt = acceptedAt;
|
|
4900
|
-
return doc;
|
|
4901
|
-
});
|
|
4902
|
-
featureWritten = true;
|
|
4903
|
-
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
4904
|
-
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
4905
|
-
phase.updatedAt = acceptedAt;
|
|
4906
|
-
return phase;
|
|
4907
|
-
});
|
|
4908
|
-
}
|
|
4909
|
-
catch (error) {
|
|
4910
|
-
// Best-effort compensation keeps a failed dual-write from reporting
|
|
4911
|
-
// success with a decision persisted only on the feature.
|
|
4912
|
-
if (featureWritten) {
|
|
4913
|
-
await st.updateFeatures((doc) => {
|
|
4914
|
-
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
4915
|
-
if (feature)
|
|
4916
|
-
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
4917
|
-
return doc;
|
|
4918
|
-
}).catch(() => { });
|
|
4919
|
-
}
|
|
4920
|
-
throw error;
|
|
4921
|
-
}
|
|
4922
|
-
}));
|
|
4923
|
-
await st.writeGenerated();
|
|
4924
|
-
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
4733
|
+
// No write happens here — see buildDecisionRecordRedirectReply. This
|
|
4734
|
+
// tool used to append the same AcceptedDecision to both the feature
|
|
4735
|
+
// and the phase; that violated the single-owner rule and mishandled
|
|
4736
|
+
// project-wide decisions discovered during task work, so it is now a
|
|
4737
|
+
// pure, non-mutating redirect (AGENTS.md rule 4: the rule and its
|
|
4738
|
+
// wording live once, in plan-core, not reinvented per adapter).
|
|
4739
|
+
const reply = buildDecisionRecordRedirectReply({
|
|
4740
|
+
featureRef: formatFeatureRef(resolvedFeature.feature.number),
|
|
4741
|
+
phaseRef: formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number),
|
|
4742
|
+
});
|
|
4743
|
+
return { content: [{ type: "text", text: `⚠️ decision_record is deprecated and write-disabled. ${reply.text}` }], details: { deprecated: true, ...reply.structured } };
|
|
4925
4744
|
},
|
|
4926
4745
|
});
|
|
4927
4746
|
pi.registerTool({
|
|
@@ -4955,7 +4774,7 @@ export default function planPiExtension(pi) {
|
|
|
4955
4774
|
return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
|
|
4956
4775
|
}
|
|
4957
4776
|
catch (error) {
|
|
4958
|
-
return
|
|
4777
|
+
return piAcceptedDecisionMutationFailure(error, "created");
|
|
4959
4778
|
}
|
|
4960
4779
|
},
|
|
4961
4780
|
});
|
|
@@ -5007,7 +4826,7 @@ export default function planPiExtension(pi) {
|
|
|
5007
4826
|
return { content: [{ type: "text", text: `✅ Accepted decision updated on ${target.ref}: ${acceptedDecision.title}` }], details: { updated: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision, updatedFields: receivedFields } };
|
|
5008
4827
|
}
|
|
5009
4828
|
catch (error) {
|
|
5010
|
-
return
|
|
4829
|
+
return piAcceptedDecisionMutationFailure(error, "updated");
|
|
5011
4830
|
}
|
|
5012
4831
|
},
|
|
5013
4832
|
});
|
|
@@ -5037,7 +4856,7 @@ export default function planPiExtension(pi) {
|
|
|
5037
4856
|
return { content: [{ type: "text", text: `✅ Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}` }], details: { deleted: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId, acceptedDecision } };
|
|
5038
4857
|
}
|
|
5039
4858
|
catch (error) {
|
|
5040
|
-
return
|
|
4859
|
+
return piAcceptedDecisionMutationFailure(error, "deleted");
|
|
5041
4860
|
}
|
|
5042
4861
|
},
|
|
5043
4862
|
});
|
|
@@ -5136,7 +4955,8 @@ export default function planPiExtension(pi) {
|
|
|
5136
4955
|
if (!st)
|
|
5137
4956
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
5138
4957
|
const features = (await st.loadFeatures()).features;
|
|
5139
|
-
const
|
|
4958
|
+
const allPhases = await st.loadAllPhases();
|
|
4959
|
+
const found = findTaskByRef(allPhases, features, params.taskId.trim());
|
|
5140
4960
|
if (!found)
|
|
5141
4961
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
5142
4962
|
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})`;
|
|
@@ -5151,7 +4971,15 @@ export default function planPiExtension(pi) {
|
|
|
5151
4971
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
5152
4972
|
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
5153
4973
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
5154
|
-
|
|
4974
|
+
// P104(F005)/T421: priority and dependsOn at the point an agent reads
|
|
4975
|
+
// the task and decides what to do next — the fact task_list and
|
|
4976
|
+
// task_recommend already have, made visible here instead of
|
|
4977
|
+
// requiring a second call.
|
|
4978
|
+
const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
|
|
4979
|
+
const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
|
|
4980
|
+
const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
|
|
4981
|
+
if (completionMismatch)
|
|
4982
|
+
sections.push(completionMismatch);
|
|
5155
4983
|
if (found.task.description?.trim())
|
|
5156
4984
|
sections.push(found.task.description.trim());
|
|
5157
4985
|
if (found.task.descriptionRef)
|
|
@@ -5178,11 +5006,56 @@ export default function planPiExtension(pi) {
|
|
|
5178
5006
|
description: found.task.description,
|
|
5179
5007
|
descriptionRef: found.task.descriptionRef ?? "",
|
|
5180
5008
|
acceptedDecisions: found.task.acceptedDecisions,
|
|
5009
|
+
priority: orderContext.priority,
|
|
5010
|
+
dependsOn: orderContext.dependsOn,
|
|
5011
|
+
...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
|
|
5012
|
+
},
|
|
5013
|
+
taskOrder: {
|
|
5014
|
+
readyCount: orderContext.readyCount,
|
|
5015
|
+
readyAheadCount: orderContext.readyAheadCount,
|
|
5016
|
+
nextByPriorityRef: orderContext.nextByPriorityRef,
|
|
5181
5017
|
},
|
|
5182
5018
|
},
|
|
5183
5019
|
};
|
|
5184
5020
|
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
5185
|
-
|
|
5021
|
+
// Advisory only (P104(F005)/T420): report whether task_start would
|
|
5022
|
+
// succeed right now, using the exact checks it runs itself, so a
|
|
5023
|
+
// caller reading the task before starting it learns what else is
|
|
5024
|
+
// needed in this same call instead of from a denial. task_start
|
|
5025
|
+
// recomputes this at call time and remains the sole authority on
|
|
5026
|
+
// whether work may begin.
|
|
5027
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
5028
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
5029
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
5030
|
+
const taskRef = `${phaseRef}/T${pad(found.task.number)}`;
|
|
5031
|
+
const linkedRequirements = [
|
|
5032
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
5033
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
5034
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
5035
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
5036
|
+
const contextEligibility = contextReadEligibilityForSession({
|
|
5037
|
+
sessionId: plannerSessionId,
|
|
5038
|
+
taskId: found.task.id,
|
|
5039
|
+
phaseId: found.phase.id,
|
|
5040
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
5041
|
+
task: found.task,
|
|
5042
|
+
phase: found.phase,
|
|
5043
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
5044
|
+
requirements: linkedRequirements,
|
|
5045
|
+
requirementIds: linkedRequirementIds,
|
|
5046
|
+
});
|
|
5047
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
5048
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
5049
|
+
const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
|
|
5050
|
+
const gateNextActions = gateState.ready ? [] : piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `task_start ${taskRef}`);
|
|
5051
|
+
return {
|
|
5052
|
+
content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
|
|
5053
|
+
details: {
|
|
5054
|
+
...result.details,
|
|
5055
|
+
taskStartGateState: gateState,
|
|
5056
|
+
...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
|
|
5057
|
+
},
|
|
5058
|
+
};
|
|
5186
5059
|
},
|
|
5187
5060
|
});
|
|
5188
5061
|
pi.registerTool({
|
|
@@ -5273,7 +5146,7 @@ export default function planPiExtension(pi) {
|
|
|
5273
5146
|
});
|
|
5274
5147
|
await st.writeGenerated();
|
|
5275
5148
|
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
5276
|
-
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
5149
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title} (${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
5277
5150
|
},
|
|
5278
5151
|
});
|
|
5279
5152
|
pi.registerTool({
|
|
@@ -5293,7 +5166,8 @@ export default function planPiExtension(pi) {
|
|
|
5293
5166
|
try {
|
|
5294
5167
|
const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5295
5168
|
await st.writeGenerated();
|
|
5296
|
-
|
|
5169
|
+
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}` });
|
|
5170
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
5297
5171
|
}
|
|
5298
5172
|
catch (error) {
|
|
5299
5173
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -5320,7 +5194,8 @@ export default function planPiExtension(pi) {
|
|
|
5320
5194
|
try {
|
|
5321
5195
|
const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
5322
5196
|
await st.writeGenerated();
|
|
5323
|
-
|
|
5197
|
+
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}` });
|
|
5198
|
+
return { content: [{ type: "text", text: reply.text }], details: reply.structured };
|
|
5324
5199
|
}
|
|
5325
5200
|
catch (error) {
|
|
5326
5201
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -5336,11 +5211,11 @@ export default function planPiExtension(pi) {
|
|
|
5336
5211
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
5337
5212
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
5338
5213
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
5339
|
-
description: Type.Optional(Type.String({ description:
|
|
5214
|
+
description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
|
|
5340
5215
|
descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
|
|
5341
5216
|
notes: Type.Optional(Type.String({ description: "New implementation notes" })),
|
|
5342
5217
|
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." })),
|
|
5343
|
-
decisions: Type.Optional(Type.Array(Type.String(), { description: "
|
|
5218
|
+
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." })),
|
|
5344
5219
|
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
5345
5220
|
id: Type.String(),
|
|
5346
5221
|
title: Type.String(),
|
|
@@ -5350,7 +5225,7 @@ export default function planPiExtension(pi) {
|
|
|
5350
5225
|
acceptedAt: Type.String(),
|
|
5351
5226
|
}), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
|
|
5352
5227
|
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
5353
|
-
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
5228
|
+
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." })),
|
|
5354
5229
|
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." })),
|
|
5355
5230
|
}),
|
|
5356
5231
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -5365,6 +5240,7 @@ export default function planPiExtension(pi) {
|
|
|
5365
5240
|
const hostPhase = found.phase;
|
|
5366
5241
|
const now = nowISO();
|
|
5367
5242
|
let updatedTask;
|
|
5243
|
+
let checklistLostTicks = [];
|
|
5368
5244
|
// Validate the motivation requirement for restrictive status transitions,
|
|
5369
5245
|
// mirroring the MCP adapter and the /planner task update command
|
|
5370
5246
|
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
@@ -5382,8 +5258,15 @@ export default function planPiExtension(pi) {
|
|
|
5382
5258
|
}
|
|
5383
5259
|
if (params.acceptedDecisions !== undefined) {
|
|
5384
5260
|
return {
|
|
5385
|
-
content: [{ type: "text", text:
|
|
5386
|
-
details: { updated: false, errorCode:
|
|
5261
|
+
content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
|
|
5262
|
+
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)}` },
|
|
5263
|
+
isError: true,
|
|
5264
|
+
};
|
|
5265
|
+
}
|
|
5266
|
+
if (params.decisions !== undefined) {
|
|
5267
|
+
return {
|
|
5268
|
+
content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
|
|
5269
|
+
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)}` },
|
|
5387
5270
|
isError: true,
|
|
5388
5271
|
};
|
|
5389
5272
|
}
|
|
@@ -5402,10 +5285,10 @@ export default function planPiExtension(pi) {
|
|
|
5402
5285
|
if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
|
|
5403
5286
|
return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
|
|
5404
5287
|
}
|
|
5405
|
-
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "
|
|
5288
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
|
|
5406
5289
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
5407
5290
|
if (receivedFields.length === 0) {
|
|
5408
|
-
return
|
|
5291
|
+
return piMutationNoFieldsFailure({
|
|
5409
5292
|
entity: "task",
|
|
5410
5293
|
ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
|
|
5411
5294
|
operation: "update",
|
|
@@ -5437,14 +5320,10 @@ export default function planPiExtension(pi) {
|
|
|
5437
5320
|
}
|
|
5438
5321
|
if (params.notes !== undefined)
|
|
5439
5322
|
task.notes = params.notes.trim();
|
|
5440
|
-
if (params.decisions !== undefined)
|
|
5441
|
-
task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
|
|
5442
5323
|
if (params.checklist !== undefined) {
|
|
5443
|
-
task.checklist
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
checked: false,
|
|
5447
|
-
}));
|
|
5324
|
+
const replacement = replaceChecklist(task.checklist ?? [], params.checklist, task.id);
|
|
5325
|
+
task.checklist = replacement.items;
|
|
5326
|
+
checklistLostTicks = replacement.lostTicks;
|
|
5448
5327
|
}
|
|
5449
5328
|
if (params.subtasks !== undefined) {
|
|
5450
5329
|
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 }));
|
|
@@ -5488,10 +5367,22 @@ export default function planPiExtension(pi) {
|
|
|
5488
5367
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
5489
5368
|
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
5490
5369
|
.map((entry) => entry.ownerRef) ?? [];
|
|
5491
|
-
const
|
|
5492
|
-
?
|
|
5370
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
5371
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
5493
5372
|
: "";
|
|
5494
|
-
|
|
5373
|
+
const taskUpdateReply = buildMutationReply({
|
|
5374
|
+
identity: { entity: "task", ref: updatedTask.id, id: updatedTask.id, shortId: updatedTask.shortId, title: updatedTask.title, status: updatedTask.status },
|
|
5375
|
+
operation: "update",
|
|
5376
|
+
updatedFields: receivedFields,
|
|
5377
|
+
changedValues: updatedTask,
|
|
5378
|
+
extras: {
|
|
5379
|
+
...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
|
|
5380
|
+
...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
|
|
5381
|
+
},
|
|
5382
|
+
notices: [lostTicksNotice],
|
|
5383
|
+
readBackCommand: `task_get ${updatedTask.id}`,
|
|
5384
|
+
});
|
|
5385
|
+
return { content: [{ type: "text", text: taskUpdateReply.text }], details: taskUpdateReply.structured };
|
|
5495
5386
|
},
|
|
5496
5387
|
});
|
|
5497
5388
|
pi.registerTool({
|
|
@@ -5545,16 +5436,10 @@ export default function planPiExtension(pi) {
|
|
|
5545
5436
|
})),
|
|
5546
5437
|
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
5547
5438
|
});
|
|
5548
|
-
const
|
|
5549
|
-
if (!selection.candidate)
|
|
5550
|
-
return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
|
|
5551
|
-
const { candidate } = selection;
|
|
5552
|
-
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
5553
|
-
const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
5554
|
-
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
5439
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
5555
5440
|
return {
|
|
5556
|
-
content: [{ type: "text", text:
|
|
5557
|
-
details:
|
|
5441
|
+
content: [{ type: "text", text: reply.text }],
|
|
5442
|
+
details: reply.structured,
|
|
5558
5443
|
};
|
|
5559
5444
|
},
|
|
5560
5445
|
});
|
|
@@ -5875,7 +5760,8 @@ export default function planPiExtension(pi) {
|
|
|
5875
5760
|
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
5876
5761
|
return st.runBatch(async () => {
|
|
5877
5762
|
const features = (await st.loadFeatures()).features;
|
|
5878
|
-
const
|
|
5763
|
+
const initialPhases = await st.loadAllPhases();
|
|
5764
|
+
const found = findTaskByRef(initialPhases, features, params.taskId.trim());
|
|
5879
5765
|
if (!found)
|
|
5880
5766
|
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
5881
5767
|
const task = found.task;
|
|
@@ -5886,6 +5772,13 @@ export default function planPiExtension(pi) {
|
|
|
5886
5772
|
if (task.status === "done")
|
|
5887
5773
|
return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_reopen with taskId=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry task_start ${taskRef} only if reopening is not the intended operation.`], { taskId: task.id }));
|
|
5888
5774
|
const project = await st.loadProject();
|
|
5775
|
+
// Targeted stale-context refresh (P102(F005)/T404): task_start delivery
|
|
5776
|
+
// stays scoped to feature/phase/task context, but a session that
|
|
5777
|
+
// already did a full planner-load gets a non-blocking advisory when
|
|
5778
|
+
// project-level context has drifted since, instead of silently going
|
|
5779
|
+
// stale.
|
|
5780
|
+
const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
|
|
5781
|
+
const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
|
|
5889
5782
|
const linkedRequirements = [
|
|
5890
5783
|
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
5891
5784
|
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
@@ -5927,7 +5820,9 @@ export default function planPiExtension(pi) {
|
|
|
5927
5820
|
}
|
|
5928
5821
|
if (task.status === "in-progress") {
|
|
5929
5822
|
const outcome = taskStartSucceeded(task.id, true);
|
|
5930
|
-
|
|
5823
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
5824
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
5825
|
+
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 } };
|
|
5931
5826
|
}
|
|
5932
5827
|
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
5933
5828
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
@@ -6016,9 +5911,11 @@ export default function planPiExtension(pi) {
|
|
|
6016
5911
|
return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
|
|
6017
5912
|
}
|
|
6018
5913
|
const outcome = taskStartSucceeded(persistedTask.id);
|
|
5914
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
5915
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
6019
5916
|
return {
|
|
6020
|
-
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
6021
|
-
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
5917
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}` }],
|
|
5918
|
+
details: { ...persistedTask, ...outcome, task: persistedTask, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase },
|
|
6022
5919
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
6023
5920
|
};
|
|
6024
5921
|
});
|
|
@@ -6027,10 +5924,11 @@ export default function planPiExtension(pi) {
|
|
|
6027
5924
|
pi.registerTool({
|
|
6028
5925
|
name: "task_complete",
|
|
6029
5926
|
label: "Task Complete",
|
|
6030
|
-
description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically.
|
|
5927
|
+
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.",
|
|
6031
5928
|
parameters: Type.Object({
|
|
6032
5929
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
6033
5930
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
5931
|
+
motivation: Type.Optional(Type.String({ description: "Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work." })),
|
|
6034
5932
|
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." }),
|
|
6035
5933
|
}),
|
|
6036
5934
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -6049,13 +5947,18 @@ export default function planPiExtension(pi) {
|
|
|
6049
5947
|
return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
|
|
6050
5948
|
if (task.pauseSnapshot)
|
|
6051
5949
|
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 };
|
|
6052
|
-
const
|
|
6053
|
-
if (
|
|
5950
|
+
const gate = evaluateTaskCompletionGate(task.checklist, params.force === true, params.motivation);
|
|
5951
|
+
if (!gate.allowed) {
|
|
5952
|
+
const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
|
|
5953
|
+
? taskCompletionForceMotivationRequired(gate.uncheckedItems)
|
|
5954
|
+
: taskCompletionChecklistRefusal(gate.uncheckedItems, `task_checklist_toggle ${params.taskId.trim()} <item>`);
|
|
6054
5955
|
return {
|
|
6055
|
-
content: [{ type: "text", text:
|
|
6056
|
-
details: { task, uncheckedChecklistItems:
|
|
5956
|
+
content: [{ type: "text", text: message }],
|
|
5957
|
+
details: { task, errorCode: gate.errorCode, uncheckedChecklistItems: gate.uncheckedItems },
|
|
6057
5958
|
};
|
|
6058
5959
|
}
|
|
5960
|
+
const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, params.motivation);
|
|
5961
|
+
const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
|
|
6059
5962
|
const now = nowISO();
|
|
6060
5963
|
let completedTask;
|
|
6061
5964
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -6067,10 +5970,10 @@ export default function planPiExtension(pi) {
|
|
|
6067
5970
|
t.statusLog = [...t.statusLog, {
|
|
6068
5971
|
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
6069
5972
|
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
6070
|
-
title: `${previousStatus} → done`, description:
|
|
5973
|
+
title: `${previousStatus} → done`, description: completionEvidence,
|
|
6071
5974
|
}];
|
|
6072
5975
|
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
6073
|
-
t.description = t.description + sep +
|
|
5976
|
+
t.description = t.description + sep + completionEvidence;
|
|
6074
5977
|
t.descriptionUpdatedAt = now;
|
|
6075
5978
|
t.updatedAt = now;
|
|
6076
5979
|
phase.updatedAt = now;
|
|
@@ -6277,9 +6180,11 @@ export default function planPiExtension(pi) {
|
|
|
6277
6180
|
pi.registerTool({
|
|
6278
6181
|
name: "planner-load",
|
|
6279
6182
|
label: "Planner Load",
|
|
6280
|
-
description: "Enable the planner for this project and start the web dashboard (LAN), then return a resume recap (project
|
|
6281
|
-
parameters: Type.Object({
|
|
6282
|
-
|
|
6183
|
+
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.",
|
|
6184
|
+
parameters: Type.Object({
|
|
6185
|
+
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.` })),
|
|
6186
|
+
}),
|
|
6187
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
6283
6188
|
const st = await requirePlan(ctx);
|
|
6284
6189
|
if (!st)
|
|
6285
6190
|
return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
|
|
@@ -6311,7 +6216,20 @@ export default function planPiExtension(pi) {
|
|
|
6311
6216
|
}
|
|
6312
6217
|
if (preparation.changed)
|
|
6313
6218
|
recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
|
|
6314
|
-
|
|
6219
|
+
// Explicit planner load must deliver the complete project-level context
|
|
6220
|
+
// (P102(F005)/T404), attested through the same lossless contract used
|
|
6221
|
+
// everywhere else — shared with the /planner load command and MCP's
|
|
6222
|
+
// planner-load tool via buildProjectContextLoadReply.
|
|
6223
|
+
const boundedMaxChars = params.maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
|
|
6224
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
|
|
6225
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
|
|
6226
|
+
if (projectContextReply.structured.contextComplete) {
|
|
6227
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
6228
|
+
}
|
|
6229
|
+
// The project context text (like the rest of `details`) is agent-only:
|
|
6230
|
+
// read and retain it, but never quote it or its Accepted Decisions in
|
|
6231
|
+
// the human-facing recap (`content[].text` stays the recap alone).
|
|
6232
|
+
return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort, preparation: preparation.legacyProjectContext, plannerSkill: { status: plannerSkill.status, customized: plannerSkill.customized, message: plannerSkill.message }, projectContext: { text: projectContextReply.text, ...projectContextReply.structured } } };
|
|
6315
6233
|
},
|
|
6316
6234
|
});
|
|
6317
6235
|
pi.registerTool({
|
|
@@ -6491,7 +6409,7 @@ export default function planPiExtension(pi) {
|
|
|
6491
6409
|
"Operational rules:",
|
|
6492
6410
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
6493
6411
|
"- 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.",
|
|
6494
|
-
|
|
6412
|
+
`- ${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.`,
|
|
6495
6413
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
6496
6414
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
6497
6415
|
"- 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).",
|