@agent-plan/core 0.2.29 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/accepted-decision-guard.d.ts +94 -0
  2. package/dist/accepted-decision-guard.d.ts.map +1 -0
  3. package/dist/accepted-decision-guard.js +110 -0
  4. package/dist/checklist.d.ts +28 -0
  5. package/dist/checklist.d.ts.map +1 -1
  6. package/dist/checklist.js +59 -0
  7. package/dist/feature-delete.d.ts +62 -0
  8. package/dist/feature-delete.d.ts.map +1 -0
  9. package/dist/feature-delete.js +49 -0
  10. package/dist/guard-decision.d.ts +128 -0
  11. package/dist/guard-decision.d.ts.map +1 -0
  12. package/dist/guard-decision.js +269 -0
  13. package/dist/guard-state.d.ts +14 -0
  14. package/dist/guard-state.d.ts.map +1 -0
  15. package/dist/guard-state.js +35 -0
  16. package/dist/handoff-context.d.ts +130 -1
  17. package/dist/handoff-context.d.ts.map +1 -1
  18. package/dist/handoff-context.js +167 -9
  19. package/dist/handoff-reply.d.ts +113 -0
  20. package/dist/handoff-reply.d.ts.map +1 -0
  21. package/dist/handoff-reply.js +279 -0
  22. package/dist/index.d.ts +18 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +18 -1
  25. package/dist/load-reply.d.ts +63 -0
  26. package/dist/load-reply.d.ts.map +1 -0
  27. package/dist/load-reply.js +64 -0
  28. package/dist/mutation-reply.d.ts +105 -0
  29. package/dist/mutation-reply.d.ts.map +1 -0
  30. package/dist/mutation-reply.js +203 -0
  31. package/dist/naming.d.ts +1 -1
  32. package/dist/naming.d.ts.map +1 -1
  33. package/dist/naming.js +1 -1
  34. package/dist/on-hold.d.ts +55 -0
  35. package/dist/on-hold.d.ts.map +1 -0
  36. package/dist/on-hold.js +123 -0
  37. package/dist/package-version.d.ts +13 -0
  38. package/dist/package-version.d.ts.map +1 -1
  39. package/dist/package-version.js +32 -0
  40. package/dist/pagination.d.ts +19 -0
  41. package/dist/pagination.d.ts.map +1 -0
  42. package/dist/pagination.js +15 -0
  43. package/dist/payload-fallback.js +1 -1
  44. package/dist/plan-store.d.ts +141 -8
  45. package/dist/plan-store.d.ts.map +1 -1
  46. package/dist/plan-store.js +459 -87
  47. package/dist/project-context-migration.d.ts +5 -0
  48. package/dist/project-context-migration.d.ts.map +1 -1
  49. package/dist/project-context-migration.js +13 -0
  50. package/dist/project-context.d.ts +397 -0
  51. package/dist/project-context.d.ts.map +1 -0
  52. package/dist/project-context.js +294 -0
  53. package/dist/recap.d.ts.map +1 -1
  54. package/dist/recap.js +33 -0
  55. package/dist/recommend-reply.d.ts +51 -0
  56. package/dist/recommend-reply.d.ts.map +1 -0
  57. package/dist/recommend-reply.js +93 -0
  58. package/dist/refs.d.ts +16 -0
  59. package/dist/refs.d.ts.map +1 -1
  60. package/dist/refs.js +38 -0
  61. package/dist/renderer.d.ts.map +1 -1
  62. package/dist/schema.d.ts +7 -2
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +8 -3
  65. package/dist/stranded-phase.d.ts +73 -0
  66. package/dist/stranded-phase.d.ts.map +1 -0
  67. package/dist/stranded-phase.js +93 -0
  68. package/dist/task-completion-gate.d.ts +84 -0
  69. package/dist/task-completion-gate.d.ts.map +1 -0
  70. package/dist/task-completion-gate.js +72 -0
  71. package/dist/task-context.d.ts +1 -0
  72. package/dist/task-context.d.ts.map +1 -1
  73. package/dist/task-context.js +42 -14
  74. package/dist/task-lifecycle.d.ts +12 -0
  75. package/dist/task-lifecycle.d.ts.map +1 -0
  76. package/dist/task-lifecycle.js +23 -0
  77. package/dist/task-order-context.d.ts +72 -0
  78. package/dist/task-order-context.d.ts.map +1 -0
  79. package/dist/task-order-context.js +82 -0
  80. package/dist/task-selection.d.ts +4 -1
  81. package/dist/task-selection.d.ts.map +1 -1
  82. package/dist/task-selection.js +36 -14
  83. package/dist/task-start-outcome.d.ts +26 -0
  84. package/dist/task-start-outcome.d.ts.map +1 -1
  85. package/dist/task-start-outcome.js +23 -0
  86. package/dist/text-bounds.d.ts +32 -0
  87. package/dist/text-bounds.d.ts.map +1 -0
  88. package/dist/text-bounds.js +43 -0
  89. package/dist/write-coordination.d.ts +2 -2
  90. package/dist/write-coordination.js +2 -2
  91. package/package.json +1 -1
  92. package/planner-skill.md +30 -12
@@ -0,0 +1,294 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { AcceptedDecisionSchema, DescriptionRefSchema, MacroTaskSchema, TimestampSchema, } from "./schema.js";
4
+ import { renderAcceptedDecisionsSection } from "./task-context.js";
5
+ export const PROJECT_CONTEXT_DELIVERY_VERSION = 1;
6
+ export const DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS = 32_000;
7
+ export const ProjectContextReadAttestationSchema = z.object({
8
+ sessionId: z.string().min(1),
9
+ createdAt: TimestampSchema,
10
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
11
+ });
12
+ export const ProjectContextReadStateDocumentSchema = z.object({
13
+ version: z.literal(PROJECT_CONTEXT_DELIVERY_VERSION).default(PROJECT_CONTEXT_DELIVERY_VERSION),
14
+ sessionInfo: z.array(ProjectContextReadAttestationSchema).default([]),
15
+ });
16
+ const ProjectContextRequirementSchema = z.object({
17
+ id: z.string(),
18
+ title: z.string().min(1),
19
+ description: z.string().default(""),
20
+ macroTasks: z.array(MacroTaskSchema).default([]),
21
+ linkedPhaseIds: z.array(z.string().min(1)).default([]),
22
+ createdAt: TimestampSchema,
23
+ updatedAt: TimestampSchema,
24
+ });
25
+ export const ProjectContextSnapshotSchema = z.object({
26
+ version: z.literal(PROJECT_CONTEXT_DELIVERY_VERSION),
27
+ project: z.object({
28
+ name: z.string().min(1),
29
+ goal: z.string(),
30
+ description: z.string(),
31
+ descriptionRef: DescriptionRefSchema.optional(),
32
+ scope: z.array(z.string().min(1)),
33
+ outOfScope: z.array(z.string().min(1)),
34
+ technologies: z.array(z.string().min(1)),
35
+ tools: z.array(z.string().min(1)),
36
+ contentLanguage: z.string(),
37
+ chatLanguage: z.string(),
38
+ projectGuidelines: z.object({
39
+ content: z.string(),
40
+ updatedAt: z.string(),
41
+ }),
42
+ acceptedDecisions: z.array(AcceptedDecisionSchema),
43
+ }),
44
+ requirements: z.array(ProjectContextRequirementSchema),
45
+ });
46
+ export const ProjectContextChunkSchema = z.object({
47
+ version: z.literal(PROJECT_CONTEXT_DELIVERY_VERSION),
48
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
49
+ status: z.literal("partial"),
50
+ complete: z.literal(false),
51
+ truncated: z.boolean(),
52
+ deliveredChunks: z.literal(1),
53
+ index: z.number().int().nonnegative(),
54
+ totalChunks: z.number().int().positive(),
55
+ cursor: z.string().min(1).optional(),
56
+ nextCursor: z.string().min(1).optional(),
57
+ content: z.string(),
58
+ });
59
+ export class ProjectContextDeliveryError extends Error {
60
+ code;
61
+ constructor(code, message) {
62
+ super(message);
63
+ this.name = "ProjectContextDeliveryError";
64
+ this.code = code;
65
+ }
66
+ }
67
+ /** Build the content-only project snapshot used by every harness. */
68
+ export function createProjectContextSnapshot(project, requirements) {
69
+ return ProjectContextSnapshotSchema.parse({
70
+ version: PROJECT_CONTEXT_DELIVERY_VERSION,
71
+ project: {
72
+ name: project.name,
73
+ goal: project.goal,
74
+ description: project.description,
75
+ ...(project.descriptionRef ? { descriptionRef: project.descriptionRef } : {}),
76
+ scope: project.scope,
77
+ outOfScope: project.outOfScope,
78
+ technologies: project.technologies,
79
+ tools: project.tools,
80
+ contentLanguage: project.contentLanguage,
81
+ chatLanguage: project.chatLanguage,
82
+ projectGuidelines: {
83
+ content: project.projectGuidelines.content,
84
+ updatedAt: project.projectGuidelines.updatedAt,
85
+ },
86
+ acceptedDecisions: project.acceptedDecisions,
87
+ },
88
+ requirements: requirements.requirements.map((requirement) => ({
89
+ id: requirement.id,
90
+ title: requirement.title,
91
+ description: requirement.description,
92
+ macroTasks: requirement.macroTasks,
93
+ linkedPhaseIds: requirement.linkedPhaseIds,
94
+ createdAt: requirement.createdAt,
95
+ updatedAt: requirement.updatedAt,
96
+ })),
97
+ });
98
+ }
99
+ export function serializeProjectContext(snapshot) {
100
+ return JSON.stringify(ProjectContextSnapshotSchema.parse(snapshot));
101
+ }
102
+ export function projectContextFingerprint(snapshot) {
103
+ return createHash("sha256").update(serializeProjectContext(snapshot), "utf8").digest("hex");
104
+ }
105
+ /**
106
+ * Split canonical JSON without dropping data. Individual chunks never assert
107
+ * completeness; only verifyProjectContextChunks can produce complete evidence.
108
+ */
109
+ export function createProjectContextChunks(snapshot, maxChars = DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS) {
110
+ if (!Number.isInteger(maxChars) || maxChars < 1) {
111
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INVALID", "Project-context chunk size must be a positive integer.");
112
+ }
113
+ const serialized = serializeProjectContext(snapshot);
114
+ const fingerprint = projectContextFingerprint(snapshot);
115
+ const parts = serialized.length === 0
116
+ ? [""]
117
+ : Array.from({ length: Math.ceil(serialized.length / maxChars) }, (_, index) => serialized.slice(index * maxChars, (index + 1) * maxChars));
118
+ return parts.map((content, index) => ({
119
+ version: PROJECT_CONTEXT_DELIVERY_VERSION,
120
+ fingerprint,
121
+ status: "partial",
122
+ complete: false,
123
+ truncated: parts.length > 1,
124
+ deliveredChunks: 1,
125
+ index,
126
+ totalChunks: parts.length,
127
+ ...(parts.length > 1 ? { cursor: `${fingerprint}:${index}` } : {}),
128
+ ...(index + 1 < parts.length ? { nextCursor: `${fingerprint}:${index + 1}` } : {}),
129
+ content,
130
+ }));
131
+ }
132
+ /** Verify an exact, ordered, complete chunk sequence and reconstruct its DTO. */
133
+ export function verifyProjectContextChunks(chunks) {
134
+ if (chunks.length === 0) {
135
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INCOMPLETE", "Project-context delivery contains no chunks.");
136
+ }
137
+ let parsedChunks;
138
+ try {
139
+ parsedChunks = ProjectContextChunkSchema.array().parse(chunks);
140
+ }
141
+ catch (error) {
142
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INVALID", `Project-context chunk metadata is invalid: ${error instanceof Error ? error.message : String(error)}`);
143
+ }
144
+ const first = parsedChunks[0];
145
+ const receivedIndexes = parsedChunks.map((chunk) => chunk.index);
146
+ if (parsedChunks.length !== first.totalChunks || parsedChunks.some((chunk, index) => chunk.index !== index)) {
147
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INCOMPLETE", `Project-context delivery is incomplete: received ordered chunks [${receivedIndexes.join(", ")}], expected indexes 0 through ${first.totalChunks - 1}.`);
148
+ }
149
+ const chunked = first.totalChunks > 1;
150
+ if (parsedChunks.some((chunk) => chunk.version !== PROJECT_CONTEXT_DELIVERY_VERSION
151
+ || chunk.fingerprint !== first.fingerprint
152
+ || chunk.totalChunks !== first.totalChunks
153
+ || chunk.truncated !== chunked
154
+ || (chunked ? chunk.cursor !== `${first.fingerprint}:${chunk.index}` : chunk.cursor !== undefined)
155
+ || (chunk.index + 1 < chunk.totalChunks
156
+ ? chunk.nextCursor !== `${first.fingerprint}:${chunk.index + 1}`
157
+ : chunk.nextCursor !== undefined))) {
158
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INVALID", "Project-context chunks have inconsistent version, fingerprint, truncation, cursor, or sequence metadata.");
159
+ }
160
+ const serialized = parsedChunks.map((chunk) => chunk.content).join("");
161
+ let snapshot;
162
+ try {
163
+ snapshot = ProjectContextSnapshotSchema.parse(JSON.parse(serialized));
164
+ }
165
+ catch (error) {
166
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INVALID", `Project-context chunks do not reconstruct a valid canonical snapshot: ${error instanceof Error ? error.message : String(error)}`);
167
+ }
168
+ const fingerprint = projectContextFingerprint(snapshot);
169
+ if (fingerprint !== first.fingerprint) {
170
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_INVALID", "Project-context fingerprint does not match the reconstructed canonical snapshot.");
171
+ }
172
+ return {
173
+ snapshot,
174
+ chunks: parsedChunks,
175
+ evidence: {
176
+ version: PROJECT_CONTEXT_DELIVERY_VERSION,
177
+ fingerprint,
178
+ status: "complete",
179
+ complete: true,
180
+ truncated: false,
181
+ chunked,
182
+ deliveredChunks: parsedChunks.length,
183
+ totalChunks: parsedChunks.length,
184
+ serializedChars: serialized.length,
185
+ },
186
+ };
187
+ }
188
+ export function createCompleteProjectContextDelivery(project, requirements, maxChars = DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS) {
189
+ const snapshot = createProjectContextSnapshot(project, requirements);
190
+ return verifyProjectContextChunks(createProjectContextChunks(snapshot, maxChars));
191
+ }
192
+ export function projectContextReadState(snapshot, attestations, sessionId) {
193
+ const normalizedSessionId = sessionId.trim();
194
+ if (!normalizedSessionId)
195
+ return "missing";
196
+ const attestation = attestations?.find((entry) => entry.sessionId === normalizedSessionId);
197
+ if (!attestation)
198
+ return "missing";
199
+ return attestation.fingerprint === projectContextFingerprint(snapshot) ? "valid" : "stale";
200
+ }
201
+ /**
202
+ * Non-blocking task-start advisory for when project-level context (delivered
203
+ * through explicit planner load) has drifted since this session's last
204
+ * attested read. Task-start's own gates stay scoped to feature/phase/task
205
+ * context (P102(F005)/T404 keeps that delivery separate and un-duplicated);
206
+ * this only signals that a targeted planner-load refresh is due, without
207
+ * blocking the task from starting.
208
+ */
209
+ export function projectContextStaleAdvisory(state) {
210
+ if (state !== "stale")
211
+ return "";
212
+ return "\n\n⚠️ Project-context advisory: project-level context (description, scope, technologies, tools, languages, Requirements, or project Accepted Decisions) changed since your last planner-load. Call planner-load again to refresh it before relying on it.";
213
+ }
214
+ /** Render the complete snapshot without applying a transport truncation policy. */
215
+ export function renderProjectContext(snapshot) {
216
+ const { project, requirements } = snapshot;
217
+ const lines = [
218
+ `Project context v${snapshot.version}`,
219
+ `Name: ${project.name}`,
220
+ `Short description: ${project.description || "(not set)"}`,
221
+ `Description reference: ${project.descriptionRef ?? "(not set)"}`,
222
+ `Goal: ${project.goal || "(not set)"}`,
223
+ `Scope: ${project.scope.join("; ") || "(none)"}`,
224
+ `Out of scope: ${project.outOfScope.join("; ") || "(none)"}`,
225
+ `Technologies: ${project.technologies.join("; ") || "(none)"}`,
226
+ `Tools: ${project.tools.join("; ") || "(none)"}`,
227
+ `Content language: ${project.contentLanguage || "(not set)"}`,
228
+ `Chat language: ${project.chatLanguage || "(not set)"}`,
229
+ "",
230
+ "Project Guidelines:",
231
+ project.projectGuidelines.content || "(none)",
232
+ `Guidelines updated at: ${project.projectGuidelines.updatedAt || "(not set)"}`,
233
+ "",
234
+ `Requirements (${requirements.length}):`,
235
+ ];
236
+ if (requirements.length === 0) {
237
+ lines.push(" - None.");
238
+ }
239
+ else {
240
+ for (const requirement of requirements) {
241
+ lines.push(` - ${requirement.id} — ${requirement.title}`);
242
+ lines.push(` Description: ${requirement.description || "(not provided)"}`);
243
+ lines.push(` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`);
244
+ lines.push(` Created at: ${requirement.createdAt}`);
245
+ lines.push(` Updated at: ${requirement.updatedAt}`);
246
+ lines.push(` Macro tasks (${requirement.macroTasks.length}):`);
247
+ if (requirement.macroTasks.length === 0)
248
+ lines.push(" - None.");
249
+ for (const macroTask of requirement.macroTasks) {
250
+ lines.push(` - ${macroTask.id} — ${macroTask.title} (${macroTask.status})`);
251
+ lines.push(` Description: ${macroTask.description || "(not provided)"}`);
252
+ lines.push(` Created at: ${macroTask.createdAt}`);
253
+ lines.push(` Updated at: ${macroTask.updatedAt}`);
254
+ }
255
+ }
256
+ }
257
+ lines.push("", renderAcceptedDecisionsSection("Project Accepted Decisions", project.acceptedDecisions));
258
+ return lines.join("\n");
259
+ }
260
+ /**
261
+ * Build the reply for an explicit planner-load project-context delivery.
262
+ * `delivery` must come straight from `PlanStore.loadProjectContextDelivery`.
263
+ * This function only shapes the reply and never mutates; the caller is
264
+ * responsible for calling `PlanStore.recordProjectContextRead` with
265
+ * `delivery.chunks` immediately afterward, and only when, this returns
266
+ * `structured.contextComplete === true`.
267
+ */
268
+ export function buildProjectContextLoadReply(delivery, maxChars) {
269
+ if (delivery.chunks.length > 1) {
270
+ return {
271
+ text: `Project context exceeds the ${maxChars}-character single-response bound (${delivery.evidence.serializedChars} characters across ${delivery.evidence.totalChunks} chunks). Project-level context (description, scope, out-of-scope, technologies, tools, languages, Project Guidelines, every Requirement, and every project Accepted Decision) was NOT delivered this turn, and the read was not attested.`,
272
+ structured: {
273
+ loaded: false,
274
+ contextComplete: false,
275
+ fingerprint: delivery.evidence.fingerprint,
276
+ totalChunks: delivery.evidence.totalChunks,
277
+ serializedChars: delivery.evidence.serializedChars,
278
+ nextActions: [
279
+ `Retry planner-load with a larger maxChars (at least ${delivery.evidence.serializedChars}) so the complete project context fits in one response.`,
280
+ "Do not treat this response as a complete project-context read: REQUIREMENTS_READ_REQUIRED and equivalent gates remain unsatisfied until a complete read is attested.",
281
+ ],
282
+ },
283
+ };
284
+ }
285
+ return {
286
+ text: renderProjectContext(delivery.snapshot),
287
+ structured: {
288
+ loaded: true,
289
+ contextComplete: true,
290
+ fingerprint: delivery.evidence.fingerprint,
291
+ requirementCount: delivery.snapshot.requirements.length,
292
+ },
293
+ };
294
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"recap.d.ts","sourceRoot":"","sources":["../src/recap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,GAAE,YAAiB,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4QhH"}
1
+ {"version":3,"file":"recap.d.ts","sourceRoot":"","sources":["../src/recap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAOjD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,GAAE,YAAiB,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgThH"}
package/dist/recap.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { formatPhaseRef, formatTwoDigitNumber } from "./naming.js";
2
2
  import { buildPhaseWorkMap } from "./task-context.js";
3
+ import { recommendNextTask } from "./task-selection.js";
4
+ import { listOpenPhaseWork, openPhaseWorkLines } from "./stranded-phase.js";
5
+ import { listOnHoldWork, renderOnHoldWork, startedOpenPhaseIds } from "./on-hold.js";
3
6
  const fref = (n) => `F${formatTwoDigitNumber(n)}`;
4
7
  const tref = (n) => `T${formatTwoDigitNumber(n)}`;
5
8
  /**
@@ -74,6 +77,20 @@ export async function buildRecap(st, web = {}, opts = {}) {
74
77
  lines.push(italian
75
78
  ? `Avanzamento: feature ${doneF}/${totalF} completate (${activeF} attive) · fasi ${doneP}/${totalP} completate (${activeP} attive) · task ${doneT}/${totalT} completati (${activeT} attivi, ${checkpointedT} con checkpoint)`
76
79
  : `Progress: Features ${doneF}/${totalF} done (${activeF} active) · Phases ${doneP}/${totalP} done (${activeP} active) · Tasks ${doneT}/${totalT} done (${activeT} active, ${checkpointedT} with checkpoints)`);
80
+ // P104(F005)/T422: a phase left in-progress with no active task had no
81
+ // signal anywhere cheap to read — this states it, priority-ordered, every
82
+ // recap, instead of leaving it discoverable only by paying for
83
+ // task_recommend's full claim evidence.
84
+ const openPhaseWork = listOpenPhaseWork(phases, feats);
85
+ const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
86
+ if (openPhaseWorkText) {
87
+ lines.push("", italian ? "In-progress: fasi con lavoro rimanente (ordine di priorità):" : "In-progress phases with work left (priority order):", openPhaseWorkText);
88
+ }
89
+ // Parked work in phases already under way, with its reason and whether
90
+ // what it waits on has finished — the same list task_recommend shows.
91
+ const onHoldText = renderOnHoldWork(listOnHoldWork(feats, phases, startedOpenPhaseIds(phases)), 5);
92
+ if (onHoldText)
93
+ lines.push("", onHoldText);
77
94
  if (activeTasks.length > 1) {
78
95
  const conflicts = activeTasks.map(({ phase, task }) => {
79
96
  const feature = feats.find((entry) => entry.id === phase.featureId);
@@ -159,6 +176,22 @@ export async function buildRecap(st, web = {}, opts = {}) {
159
176
  : `⚠️ nextSteps are free-text from resume.json — may be stale; verify against current state before acting.`);
160
177
  lines.push(staleNote);
161
178
  }
179
+ else if (!planComplete) {
180
+ // No active task, resume advisory, checkpoint, pending handoff, or
181
+ // legacy resume.json nextSteps: every branch above already names a
182
+ // concrete ref to act on, but this, the ordinary case, previously fell
183
+ // through with no "Next step" line at all — the reader had to go call
184
+ // task_recommend to learn what the recap already knew (P104(F005)/T421).
185
+ const recommendation = recommendNextTask(feats, phases, plan.project.workDeviations);
186
+ if (recommendation.kind === "priority" && recommendation.candidate) {
187
+ const { candidate } = recommendation;
188
+ const candidateFeature = feats.find((entry) => entry.id === candidate.phase.featureId);
189
+ const ref = `${formatPhaseRef(candidate.phase.number, candidateFeature?.number)}/${tref(candidate.task.number)}`;
190
+ lines.push(italian
191
+ ? `Prossimo step: ${ref} — ${candidate.task.title} (priorità più bassa pronta). Avvia con ${taskStartCmd} ${ref}.`
192
+ : `Next step: ${ref} — ${candidate.task.title} (lowest-priority ready task). Start with ${taskStartCmd} ${ref}.`);
193
+ }
194
+ }
162
195
  if (pendingResume) {
163
196
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
164
197
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared reply shaping for `planner-task-recommend` / `task_recommend`.
3
+ *
4
+ * Measured on this repository's own planner (P104(F005)/T419): the raw
5
+ * result of `recommendNextWork` was 48,869 characters, because both
6
+ * adapters spread `selection` whole into the reply. `selection.candidate`
7
+ * carries full `Feature`/`Phase`/`Task` entities — every sibling task's
8
+ * complete description and completion summary, every checklist, every
9
+ * statusLog entry, the whole persisted handoff body — to answer a question
10
+ * whose only useful payload is "which task, and why." The compact fields
11
+ * this builder already assembles (`taskId`, `phaseId`, `featureId`,
12
+ * `activeTask`, `nextFeature`, `nextPhase`, `nextTask`, `claims`) carry the
13
+ * answer; nothing reads the fat.
14
+ *
15
+ * Placed in plan-core per AGENTS.md rule 4: both adapters call
16
+ * `recommendNextWork` themselves (the pure selector stays adapter-visible
17
+ * for their own routing), then hand the result here once so neither can
18
+ * drift from the other. Follows the shape of handoff-reply.ts and
19
+ * mutation-reply.ts: one `text`/`structured` reply, built once, consumed by
20
+ * every adapter.
21
+ */
22
+ import type { Feature } from "./schema.js";
23
+ import type { NextWorkRecommendation } from "./task-selection.js";
24
+ export interface RecommendReply {
25
+ text: string;
26
+ structured: Record<string, unknown>;
27
+ }
28
+ /** What `selection.candidate` shrinks to: the composite ref and title a
29
+ * caller acts on, not the full entities behind them. */
30
+ export interface RecommendationCandidateSummary {
31
+ ref: string;
32
+ taskId: string;
33
+ phaseId: string;
34
+ featureId?: string;
35
+ title: string;
36
+ status: string;
37
+ }
38
+ /**
39
+ * Build the reply for `planner-task-recommend` / `task_recommend`, covering
40
+ * both the candidate and no-candidate branches through one shared shape.
41
+ *
42
+ * `kind`, `reason`, `taskId`, `phaseId`, `featureId`, `deviation`,
43
+ * `activeTask`, `nextFeature`, `nextPhase`, `nextTask`, `claims`, and the
44
+ * no-candidate branch's `activeTaskIds` keep their exact prior shape and
45
+ * position — the lifecycle protocol and the recommendation claims contract
46
+ * read those fields. Only `selection.candidate` (and, symmetrically,
47
+ * `selection.activeCandidates` on the conflict branch) is replaced with
48
+ * bounded summaries instead of full entities.
49
+ */
50
+ export declare function buildRecommendationReply(result: NextWorkRecommendation, features: Feature[]): RecommendReply;
51
+ //# sourceMappingURL=recommend-reply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommend-reply.d.ts","sourceRoot":"","sources":["../src/recommend-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,KAAK,EAAE,sBAAsB,EAAiB,MAAM,qBAAqB,CAAC;AAGjF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED;wDACwD;AACxD,MAAM,WAAW,8BAA8B;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAwBD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,sBAAsB,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,cAAc,CA8D5G"}
@@ -0,0 +1,93 @@
1
+ import { featureNumberOfPhase, formatPhaseRef } from "./naming.js";
2
+ import { renderOnHoldWork } from "./on-hold.js";
3
+ function compositeTaskRef(candidate, features) {
4
+ const featureNumber = featureNumberOfPhase(candidate.phase, features);
5
+ return `${formatPhaseRef(candidate.phase.number, featureNumber)}/T${String(candidate.task.number).padStart(3, "0")}`;
6
+ }
7
+ function summarizeCandidate(candidate, features) {
8
+ return {
9
+ ref: compositeTaskRef(candidate, features),
10
+ taskId: candidate.task.id,
11
+ phaseId: candidate.phase.id,
12
+ ...(candidate.feature?.id ? { featureId: candidate.feature.id } : {}),
13
+ title: candidate.task.title,
14
+ status: candidate.task.status,
15
+ };
16
+ }
17
+ function renderClaimsText(claims) {
18
+ return claims.length
19
+ ? `\nClaims:\n${claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}`
20
+ : "";
21
+ }
22
+ /**
23
+ * Build the reply for `planner-task-recommend` / `task_recommend`, covering
24
+ * both the candidate and no-candidate branches through one shared shape.
25
+ *
26
+ * `kind`, `reason`, `taskId`, `phaseId`, `featureId`, `deviation`,
27
+ * `activeTask`, `nextFeature`, `nextPhase`, `nextTask`, `claims`, and the
28
+ * no-candidate branch's `activeTaskIds` keep their exact prior shape and
29
+ * position — the lifecycle protocol and the recommendation claims contract
30
+ * read those fields. Only `selection.candidate` (and, symmetrically,
31
+ * `selection.activeCandidates` on the conflict branch) is replaced with
32
+ * bounded summaries instead of full entities.
33
+ */
34
+ export function buildRecommendationReply(result, features) {
35
+ const { selection } = result;
36
+ const onHoldBlock = renderOnHoldWork(result.onHold ?? []);
37
+ const claimsText = `${renderClaimsText(result.claims)}${onHoldBlock ? `\n\n${onHoldBlock}` : ""}`;
38
+ // Counts and refs only: the reasons are in the text.
39
+ const onHoldSummary = {
40
+ total: (result.onHold ?? []).length,
41
+ readyToResume: (result.onHold ?? []).filter((item) => item.readyToResume).map((item) => item.ref),
42
+ };
43
+ if (!selection.candidate) {
44
+ const activeCandidates = selection.activeCandidates ?? [];
45
+ return {
46
+ text: `No work recommendation: ${selection.reason}${claimsText}`,
47
+ structured: {
48
+ kind: selection.kind,
49
+ reason: selection.reason,
50
+ activeTaskIds: activeCandidates.map((candidate) => candidate.task.id),
51
+ activeTask: result.activeTask,
52
+ nextFeature: result.nextFeature,
53
+ nextPhase: result.nextPhase,
54
+ nextTask: result.nextTask,
55
+ claims: result.claims,
56
+ onHold: onHoldSummary,
57
+ selection: {
58
+ kind: selection.kind,
59
+ reason: selection.reason,
60
+ deviation: selection.deviation,
61
+ activeCandidates: activeCandidates.map((candidate) => summarizeCandidate(candidate, features)),
62
+ },
63
+ },
64
+ };
65
+ }
66
+ const { candidate } = selection;
67
+ const candidateSummary = summarizeCandidate(candidate, features);
68
+ const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
69
+ const deviationLine = selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : "";
70
+ const text = `${activeLine}Next work (${selection.kind}): ${candidateSummary.ref} — ${candidate.task.title}\n${selection.reason}${deviationLine}${claimsText}`;
71
+ return {
72
+ text,
73
+ structured: {
74
+ kind: selection.kind,
75
+ taskId: candidate.task.id,
76
+ phaseId: candidate.phase.id,
77
+ featureId: candidate.feature?.id,
78
+ deviation: selection.deviation,
79
+ activeTask: result.activeTask,
80
+ nextFeature: result.nextFeature,
81
+ nextPhase: result.nextPhase,
82
+ nextTask: result.nextTask,
83
+ claims: result.claims,
84
+ onHold: onHoldSummary,
85
+ selection: {
86
+ kind: selection.kind,
87
+ reason: selection.reason,
88
+ deviation: selection.deviation,
89
+ candidate: candidateSummary,
90
+ },
91
+ },
92
+ };
93
+ }
package/dist/refs.d.ts CHANGED
@@ -17,6 +17,22 @@ import type { Phase, Feature, Idea } from "./schema.js";
17
17
  * inclusion. Ideas are top-level and never require feature/phase context.
18
18
  */
19
19
  export declare function findIdeaByRef(ideas: Idea[], ref: string): Idea | undefined;
20
+ /**
21
+ * Resolve a feature reference to a Feature, or a typed error when it can't
22
+ * be resolved unambiguously. Unlike `findPhaseByRef`/`findTaskByRef` (which
23
+ * return `undefined` on any miss), callers of this one — accepted-decision
24
+ * target resolution, feature-scoped tools — need to tell "not found" apart
25
+ * from "ambiguous" so the message can say which. Both adapters carried an
26
+ * identical copy; kept here so the ref grammar (number, shortId, UUID, exact
27
+ * name, partial name with ambiguity detection) is one decision.
28
+ */
29
+ export declare function resolveFeatureRefStrict(features: Feature[], ref: string): {
30
+ ok: true;
31
+ feature: Feature;
32
+ } | {
33
+ ok: false;
34
+ error: string;
35
+ };
20
36
  /**
21
37
  * Resolve a phase reference to a Phase. Returns `undefined` when not found or
22
38
  * when a composite (F00x) parent does not match the phase's featureId.
@@ -1 +1 @@
1
- {"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../src/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAMxD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAmB1E;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV,KAAK,GAAG,SAAS,CAgCnB;AACD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAiBxC,wBAAgB,aAAa,CAC3B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAAG,SAAS,CA2D1C"}
1
+ {"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../src/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAOxD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAmB1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,MAAM,GACpE;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAwB/B;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV,KAAK,GAAG,SAAS,CAgCnB;AACD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAiBxC,wBAAgB,aAAa,CAC3B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAAG,SAAS,CA2D1C"}
package/dist/refs.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { formatFeatureRef } from "./naming.js";
1
2
  // P00x or P00x(F00x) — accept 1+ digits so "p1" == "p001".
2
3
  const PHASE_REF_RE = /^p(\d+)(?:\(f(\d+)\))?$/;
3
4
  const IDEA_REF_RE = /^i(\d+)$/;
@@ -25,6 +26,43 @@ export function findIdeaByRef(ideas, ref) {
25
26
  return ideas.find((idea) => idea.title.toLowerCase() === normalized)
26
27
  ?? ideas.find((idea) => idea.title.toLowerCase().includes(normalized));
27
28
  }
29
+ /**
30
+ * Resolve a feature reference to a Feature, or a typed error when it can't
31
+ * be resolved unambiguously. Unlike `findPhaseByRef`/`findTaskByRef` (which
32
+ * return `undefined` on any miss), callers of this one — accepted-decision
33
+ * target resolution, feature-scoped tools — need to tell "not found" apart
34
+ * from "ambiguous" so the message can say which. Both adapters carried an
35
+ * identical copy; kept here so the ref grammar (number, shortId, UUID, exact
36
+ * name, partial name with ambiguity detection) is one decision.
37
+ */
38
+ export function resolveFeatureRefStrict(features, ref) {
39
+ const raw = ref.trim();
40
+ if (!raw)
41
+ return { ok: false, error: "Feature ref is required." };
42
+ const normalized = raw.toLowerCase();
43
+ const byNumber = normalized.match(/^f(\d+)$/)
44
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
45
+ : undefined;
46
+ if (byNumber)
47
+ return { ok: true, feature: byNumber };
48
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
49
+ if (byShortId)
50
+ return { ok: true, feature: byShortId };
51
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
52
+ if (byId)
53
+ return { ok: true, feature: byId };
54
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
55
+ if (exactName.length === 1)
56
+ return { ok: true, feature: exactName[0] };
57
+ if (exactName.length > 1)
58
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
59
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
60
+ if (partialName.length === 1)
61
+ return { ok: true, feature: partialName[0] };
62
+ if (partialName.length > 1)
63
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
64
+ return { ok: false, error: `Feature not found: ${raw}` };
65
+ }
28
66
  /**
29
67
  * Resolve a phase reference to a Phase. Returns `undefined` when not found or
30
68
  * when a composite (F00x) parent does not match the phase's featureId.
@@ -1 +1 @@
1
- {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,OAAO,EAAQ,KAAK,EAAE,aAAa,EAAqB,MAAM,aAAa,CAAC;AAuB5G,qBAAa,YAAY;IACvB,UAAU,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM;IA+LvC,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM;IAIjC,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,gBAAgB;IA6BxB,OAAO,CAAC,eAAe;IA2GvB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM;IAmDxD,mEAAmE;IACnE,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;CAejD"}
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAQ,KAAK,EAAE,aAAa,EAAQ,MAAM,aAAa,CAAC;AAuB7E,qBAAa,YAAY;IACvB,UAAU,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM;IA+LvC,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM;IAIjC,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,gBAAgB;IA6BxB,OAAO,CAAC,eAAe;IA2GvB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM;IAmDxD,mEAAmE;IACnE,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;CAejD"}
package/dist/schema.d.ts CHANGED
@@ -799,11 +799,16 @@ export declare const ChecklistItemSchema: z.ZodObject<{
799
799
  export declare const STATUS_LOG_MOTIVATION_REQUIRED: Set<string>;
800
800
  /**
801
801
  * Returns true when a status transition requires a written motivation.
802
- * - → DONE never requires motivation.
802
+ * - → DONE never requires motivation for an ordinary completion.
803
+ * - → DONE with `forced: true` — a completion pushed through past checklist
804
+ * items still open (see task-completion-gate.ts, P104(F005)/T428) — is the
805
+ * one case where a → DONE transition does require it: the same rule this
806
+ * function already enforces for blocked/canceled/deferred/rejected,
807
+ * applied to the one case where reaching DONE is itself an override.
803
808
  * - → BLOCKED / CANCELED / DEFERRED / REJECTED / WAITING always require it.
804
809
  * - → PLANNED from a non-PLANNED status requires it.
805
810
  */
806
- export declare function needsMotivation(fromStatus: string, toStatus: string): boolean;
811
+ export declare function needsMotivation(fromStatus: string, toStatus: string, forced?: boolean): boolean;
807
812
  export declare const SessionInfoSchema: z.ZodObject<{
808
813
  sessionId: z.ZodString;
809
814
  createdAt: z.ZodString;