@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,203 @@
1
+ /**
2
+ * Shared reply shaping for every mutation tool (update, discuss, and the
3
+ * small mutations around them).
4
+ *
5
+ * The rule this file exists to hold: a mutation result says what changed.
6
+ * It does not say what the entity now contains. Before P104(F005)/T417 each
7
+ * adapter hand-built `{ ...entity, updated: true }`, so changing one field
8
+ * on this repository's own P104 returned 139,805 characters — every task
9
+ * body in the phase, echoed back to a caller who already knew them. A task
10
+ * update peaked at 33,499. The harness had to spill those results to a file,
11
+ * and reading back whether `updated` was true then needed jq.
12
+ *
13
+ * The bound lives here rather than in an adapter for the reason in AGENTS.md
14
+ * rule 4: both adapters render what the core returns, so neither can drift
15
+ * and a third harness inherits the bound without knowing it exists. Four
16
+ * separate defects in P104 came from logic duplicated per adapter.
17
+ *
18
+ * Channel discipline matches handoff-reply.ts: `text` is the human-readable
19
+ * body, `structured` is the machine-readable payload, and no field is
20
+ * carried by both.
21
+ */
22
+ import { truncateAtSafeBoundary } from "./text-bounds.js";
23
+ /**
24
+ * Per-field echo budget. A changed field is worth showing back so the caller
25
+ * can confirm what landed without a second call; a changed field the size of
26
+ * a task description is not. 2,000 characters is about three screens — long
27
+ * enough to read a title, a status, a checklist or a short note whole, short
28
+ * enough that echoing a full description is impossible.
29
+ */
30
+ export const MUTATION_ECHO_MAX_FIELD_CHARS = 2_000;
31
+ /**
32
+ * Ceiling for the whole structured payload of a mutation reply. Chosen to
33
+ * match MAX_PHASE_WORK_MAP_CHARS and MAX_ACCEPTED_DECISION_CONTEXT_CHARS so
34
+ * an agent has one number in mind for planner payloads rather than four.
35
+ * When the assembled payload exceeds it, echoed values are dropped to names
36
+ * (see `boundMutationEcho`) — the caller still learns exactly which fields
37
+ * changed, which is the part it cannot get anywhere else.
38
+ */
39
+ export const MUTATION_REPLY_MAX_CHARS = 8_000;
40
+ /**
41
+ * The long-text size a planner tool accepts in one call, stated up front so
42
+ * a caller does not discover it by being rejected. This is not a limit the
43
+ * planner imposes for its own sake: a long `description` can be dropped by
44
+ * the transport before it ever reaches the planner, which arrives here as
45
+ * "no mutable fields received" and is why DESCRIPTION_MARKDOWN_FALLBACK_REQUIRED
46
+ * exists. Measured: ~13,500 characters accepted, ~17,000 rejected. 12,000 is
47
+ * under the observed floor with room to spare.
48
+ *
49
+ * Above this, write the prose to a committed file under `.planner/docs/` and
50
+ * pass `descriptionRef` — see suggestedDescriptionRefPath in payload-fallback.ts.
51
+ */
52
+ export const PLANNER_LONG_TEXT_FIELD_MAX_CHARS = 12_000;
53
+ /**
54
+ * One sentence for a tool description, so the limit is discoverable at the
55
+ * point of the call instead of after it fails. Every tool taking a long text
56
+ * field appends this to its own description.
57
+ */
58
+ export function longTextFieldLimitNotice(field = "description") {
59
+ return `Keep ${field} under ${PLANNER_LONG_TEXT_FIELD_MAX_CHARS.toLocaleString("en-US")} characters; above that the transport may drop it before it arrives. Put longer prose in a committed .planner/docs/ file and pass ${field}Ref instead.`;
60
+ }
61
+ /**
62
+ * Bound one echoed value. A short string travels whole. A long one is cut at
63
+ * a safe boundary — paragraph, then line, then word, the same helper the
64
+ * handoff path uses — and reports its true length, so a caller can tell a
65
+ * 2,000-character field from a 20,000-character one without receiving either
66
+ * in full. Arrays and objects are measured by their serialized size and
67
+ * degrade to a count rather than a preview, because half an array read as
68
+ * whole is worse than no array at all.
69
+ */
70
+ function boundEchoedValue(value) {
71
+ if (typeof value === "string") {
72
+ if (value.length <= MUTATION_ECHO_MAX_FIELD_CHARS)
73
+ return { value, truncated: false };
74
+ const bounded = truncateAtSafeBoundary(value, MUTATION_ECHO_MAX_FIELD_CHARS);
75
+ return {
76
+ value: { preview: bounded, fullLength: value.length, truncated: true },
77
+ truncated: true,
78
+ };
79
+ }
80
+ if (value === null || value === undefined || typeof value !== "object") {
81
+ return { value, truncated: false };
82
+ }
83
+ const serialized = JSON.stringify(value) ?? "";
84
+ if (serialized.length <= MUTATION_ECHO_MAX_FIELD_CHARS)
85
+ return { value, truncated: false };
86
+ if (Array.isArray(value)) {
87
+ return { value: { count: value.length, serializedLength: serialized.length, truncated: true }, truncated: true };
88
+ }
89
+ return { value: { keys: Object.keys(value), serializedLength: serialized.length, truncated: true }, truncated: true };
90
+ }
91
+ /**
92
+ * Assemble the echo of changed fields within the reply ceiling. Fields are
93
+ * taken in the order the caller changed them and the budget is shared, so
94
+ * one large field cannot crowd out the rest: what does not fit is named in
95
+ * `omittedValues` rather than silently dropped. Naming what was left out is
96
+ * the same degradation the prepare path uses — elided must never mean
97
+ * invisible.
98
+ */
99
+ function boundMutationEcho(updatedFields, changedValues, budget) {
100
+ const changed = {};
101
+ const omittedValues = [];
102
+ const truncatedFields = [];
103
+ let remaining = budget;
104
+ for (const field of updatedFields) {
105
+ if (!(field in changedValues))
106
+ continue;
107
+ const bounded = boundEchoedValue(changedValues[field]);
108
+ const cost = (JSON.stringify(bounded.value) ?? "").length + field.length + 4;
109
+ if (cost > remaining) {
110
+ omittedValues.push(field);
111
+ continue;
112
+ }
113
+ remaining -= cost;
114
+ changed[field] = bounded.value;
115
+ if (bounded.truncated)
116
+ truncatedFields.push(field);
117
+ }
118
+ return { changed, omittedValues, truncatedFields };
119
+ }
120
+ /**
121
+ * Build the one reply every mutation tool returns.
122
+ *
123
+ * What a caller gets: that the write happened, which fields it touched, the
124
+ * canonical ref and identity of what it touched, the changed values within
125
+ * budget, and every diagnostic. What it does not get: the rest of the
126
+ * entity, which it can read with the command named in `readBack`.
127
+ *
128
+ * `updated` (or `discussed`), `updatedFields` and any `errorCode` keep their
129
+ * exact prior shape — mutation-integrity checking across the whole project
130
+ * reads those three and nothing else.
131
+ */
132
+ export function buildMutationReply(input) {
133
+ const { identity, operation, updatedFields } = input;
134
+ const fields = [...updatedFields];
135
+ const extras = input.extras ?? {};
136
+ const identityPayload = {
137
+ entity: identity.entity,
138
+ ref: identity.ref,
139
+ ...(identity.id ? { id: identity.id } : {}),
140
+ ...(identity.shortId ? { shortId: identity.shortId } : {}),
141
+ ...(identity.title ? { title: identity.title } : {}),
142
+ ...(identity.status ? { status: identity.status } : {}),
143
+ };
144
+ // Everything except the echo is non-negotiable, so the echo gets whatever
145
+ // the ceiling leaves once identity, fields and diagnostics are counted.
146
+ const fixedCost = (JSON.stringify({ ...identityPayload, updatedFields: fields, ...extras }) ?? "").length;
147
+ const { changed, omittedValues, truncatedFields } = boundMutationEcho(fields, input.changedValues ?? {}, Math.max(0, MUTATION_REPLY_MAX_CHARS - fixedCost));
148
+ const structured = {
149
+ ...(operation === "discuss" ? { discussed: true } : { updated: true }),
150
+ ...identityPayload,
151
+ updatedFields: fields,
152
+ ...(Object.keys(changed).length > 0 ? { changed } : {}),
153
+ ...(truncatedFields.length > 0 ? { truncatedFields } : {}),
154
+ ...(omittedValues.length > 0 ? { omittedValues } : {}),
155
+ ...extras,
156
+ ...(input.readBackCommand ? { readBack: input.readBackCommand } : {}),
157
+ };
158
+ const descriptor = [identity.ref, identity.title].filter(Boolean).join(" — ");
159
+ const statusSuffix = identity.status ? ` (${identity.status})` : "";
160
+ const shortIdSuffix = identity.shortId ? ` · ${identity.shortId}` : "";
161
+ const verb = operation === "discuss" ? "discussed/updated" : "updated";
162
+ const entityLabel = identity.entity.charAt(0).toUpperCase() + identity.entity.slice(1);
163
+ const lines = [
164
+ `✅ ${entityLabel} ${verb}: ${descriptor}${statusSuffix}${shortIdSuffix}. Fields saved: ${fields.join(", ")}.`,
165
+ ...(input.notices ?? []).filter((notice) => notice.trim().length > 0),
166
+ ];
167
+ return { text: lines.join(" "), structured };
168
+ }
169
+ /**
170
+ * A feature/phase `status` field cannot be set directly — it is derived
171
+ * from child phases/tasks. Both adapters carried an identical copy of this
172
+ * refusal for feature_update/phase_update; only the retry line's tool name
173
+ * differs, which is why it is the one caller-supplied piece.
174
+ */
175
+ export function derivedStatusReadOnlyReply(input) {
176
+ const { entity, ref, attemptedStatus, effectiveStatus, retryAction } = input;
177
+ const childKind = entity === "feature" ? "phases" : "tasks";
178
+ const label = `${entity[0].toUpperCase()}${entity.slice(1)}`;
179
+ const nextActions = [
180
+ `${label} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
181
+ retryAction,
182
+ ];
183
+ return {
184
+ text: [
185
+ `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
186
+ `${label} status is derived from child ${childKind} and cannot be set directly.`,
187
+ `Attempted status: ${attemptedStatus}`,
188
+ `Effective derived status: ${effectiveStatus}`,
189
+ "No planner data was changed.",
190
+ "Next required actions:",
191
+ ...nextActions.map((action, index) => `${index + 1}. ${action}`),
192
+ ].join("\n"),
193
+ structured: {
194
+ updated: false,
195
+ errorCode: "DERIVED_STATUS_READ_ONLY",
196
+ entity,
197
+ ref,
198
+ attemptedStatus,
199
+ effectiveStatus,
200
+ nextActions,
201
+ },
202
+ };
203
+ }
package/dist/naming.d.ts CHANGED
@@ -52,7 +52,7 @@ export declare function createPhaseId(): string;
52
52
  /** True for legacy phase ids that are NOT feature-scoped (e.g. `phase-01-...`). */
53
53
  export declare function isLegacyPhaseId(phaseId: string): boolean;
54
54
  /** Compute the feature-scoped id for a legacy phase, preserving featureId/number/slug. */
55
- export declare function migratePhaseId(featureId: string, number: number, slug: string): string;
55
+ export declare function migratePhaseId(_featureId: string, _number: number, _slug: string): string;
56
56
  export declare function createTaskId(): string;
57
57
  export declare function createRequirementId(): string;
58
58
  export declare function createIdeaId(): string;
@@ -1 +1 @@
1
- {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAKA;iFACiF;AACjF,eAAO,MAAM,kBAAkB,qCAAqC,CAAC;AAErE,eAAO,MAAM,eAAe,IAAI,CAAC;AACjC,eAAO,MAAM,gBAAgB,QAAkB,CAAC;AAEhD,4EAA4E;AAC5E,eAAO,MAAM,YAAY,QAAoE,CAAC;AAE9F,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEtD;AAED;;gEAEgE;AAChE,wBAAsB,sBAAsB,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EACnE,IAAI,EAAE,SAAS,GAAG,OAAO,EACzB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GACnC,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAStD;AAGD;;;;;qCAKqC;AACrC,wBAAgB,aAAa,CAAC,QAAQ,GAAE,GAAG,CAAC,MAAM,CAAa,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CA2BtF;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOnD;AAED;;;;8BAI8B;AAC9B,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,SAAK,EAAE,QAAQ,SAAa,GAAG,MAAM,CAGnF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED;;iBAEiB;AACjB,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED,4DAA4D;AAC5D,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,CAClC,KAAK,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAAE,EAChD,QAAQ,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,GACzC,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,0FAA0F;AAC1F,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAGtF;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3F;AAED,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C"}
1
+ {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAKA;iFACiF;AACjF,eAAO,MAAM,kBAAkB,qCAAqC,CAAC;AAErE,eAAO,MAAM,eAAe,IAAI,CAAC;AACjC,eAAO,MAAM,gBAAgB,QAAkB,CAAC;AAEhD,4EAA4E;AAC5E,eAAO,MAAM,YAAY,QAAoE,CAAC;AAE9F,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEtD;AAED;;gEAEgE;AAChE,wBAAsB,sBAAsB,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EACnE,IAAI,EAAE,SAAS,GAAG,OAAO,EACzB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GACnC,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAStD;AAGD;;;;;qCAKqC;AACrC,wBAAgB,aAAa,CAAC,QAAQ,GAAE,GAAG,CAAC,MAAM,CAAa,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CA2BtF;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOnD;AAED;;;;8BAI8B;AAC9B,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,SAAK,EAAE,QAAQ,SAAa,GAAG,MAAM,CAGnF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED;;iBAEiB;AACjB,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED,4DAA4D;AAC5D,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,CAClC,KAAK,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAAE,EAChD,QAAQ,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,GACzC,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,0FAA0F;AAC1F,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGzF;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3F;AAED,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C"}
package/dist/naming.js CHANGED
@@ -110,7 +110,7 @@ export function isLegacyPhaseId(phaseId) {
110
110
  return phaseId.startsWith("phase-") && !phaseId.startsWith("feature-");
111
111
  }
112
112
  /** Compute the feature-scoped id for a legacy phase, preserving featureId/number/slug. */
113
- export function migratePhaseId(featureId, number, slug) {
113
+ export function migratePhaseId(_featureId, _number, _slug) {
114
114
  // In UUID world, this is just randomUUID, but we keep signature for compatibility if needed
115
115
  return randomUUID();
116
116
  }
@@ -0,0 +1,55 @@
1
+ import type { Feature, Phase } from "./schema.js";
2
+ /**
3
+ * On-hold work that may be ready to resume.
4
+ *
5
+ * A task parked as deferred, waiting or blocked keeps its reason in the
6
+ * status log ("Resume once P097 lands"). Nothing used to look at those
7
+ * reasons again, so work whose blocker had finished stayed parked until a
8
+ * person noticed. This module is the one place that lists on-hold tasks
9
+ * with their reasons and checks the ones whose reason names planner refs:
10
+ * the recommendation and the load recap both read it, so they cannot
11
+ * disagree.
12
+ */
13
+ export declare const ON_HOLD_STATUSES: Set<string>;
14
+ export interface OnHoldRef {
15
+ /** The ref as written in the reason, normalized (e.g. `P097(F003)`, `T468`). */
16
+ ref: string;
17
+ /** Whether the named phase or task exists and is finished. */
18
+ finished: boolean;
19
+ /** False when the ref names nothing in this plan. */
20
+ found: boolean;
21
+ }
22
+ export interface OnHoldTask {
23
+ taskId: string;
24
+ ref: string;
25
+ title: string;
26
+ status: string;
27
+ phaseId: string;
28
+ phaseRef: string;
29
+ /** Why it was put on hold: the motivation of the status change that parked it. */
30
+ reason: string;
31
+ heldSince: string;
32
+ /** Planner refs named in the reason, each checked against the plan. */
33
+ namedRefs: OnHoldRef[];
34
+ /** True when the reason names at least one ref and every named ref is finished. */
35
+ readyToResume: boolean;
36
+ }
37
+ /** Resolve every planner ref named in `text`, by number only (never by title). */
38
+ export declare function refsNamedIn(text: string, phases: Phase[], features: Feature[], selfTaskId?: string): OnHoldRef[];
39
+ /**
40
+ * Every on-hold task, ready-to-resume ones first. Pass `phaseIds` to limit
41
+ * the list to the phases a caller is about to act on.
42
+ */
43
+ export declare function listOnHoldWork(features: Feature[], phases: Phase[], phaseIds?: Set<string>): OnHoldTask[];
44
+ /**
45
+ * The phases whose on-hold work matters now: phases where work has started
46
+ * (some task finished or in progress) but not every task is finished. A
47
+ * phase nobody has started yet keeps its on-hold tasks out of the way.
48
+ */
49
+ export declare function startedOpenPhaseIds(phases: Phase[]): Set<string>;
50
+ /**
51
+ * One bounded text block for any reply. Ready-to-resume tasks first; each
52
+ * line carries the reason so the agent can re-check it against the plan.
53
+ */
54
+ export declare function renderOnHoldWork(items: OnHoldTask[], limit?: number): string;
55
+ //# sourceMappingURL=on-hold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"on-hold.d.ts","sourceRoot":"","sources":["../src/on-hold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAQ,MAAM,aAAa,CAAC;AAGxD;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,gBAAgB,aAA8C,CAAC;AAG5E,MAAM,WAAW,SAAS;IACxB,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,8DAA8D;IAC9D,QAAQ,EAAE,OAAO,CAAC;IAClB,qDAAqD;IACrD,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,mFAAmF;IACnF,aAAa,EAAE,OAAO,CAAC;CACxB;AAgBD,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,UAAU,SAAK,GAAG,SAAS,EAAE,CA8B5G;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,UAAU,EAAE,CAwBzG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAKhE;AAID;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,KAAK,SAAI,GAAG,MAAM,CAiBvE"}
@@ -0,0 +1,123 @@
1
+ import { featureNumberOfPhase, formatPhaseRef } from "./naming.js";
2
+ /**
3
+ * On-hold work that may be ready to resume.
4
+ *
5
+ * A task parked as deferred, waiting or blocked keeps its reason in the
6
+ * status log ("Resume once P097 lands"). Nothing used to look at those
7
+ * reasons again, so work whose blocker had finished stayed parked until a
8
+ * person noticed. This module is the one place that lists on-hold tasks
9
+ * with their reasons and checks the ones whose reason names planner refs:
10
+ * the recommendation and the load recap both read it, so they cannot
11
+ * disagree.
12
+ */
13
+ export const ON_HOLD_STATUSES = new Set(["deferred", "waiting", "blocked"]);
14
+ const FINISHED = new Set(["done", "canceled", "rejected"]);
15
+ // A phase ref with optional feature and task, in either spelling
16
+ // (`P097`, `P097(F003)`, `F003/P097`, `P094(F011)/T449`, `F011/P094/T449`),
17
+ // or a bare task ref (`T468`). Word-bounded so prose like "HTTP2" or
18
+ // "T-shirt" never matches.
19
+ const REF_IN_TEXT = /\b(?:F(\d{1,4})\/)?P(\d{1,4})(?:\(F(\d{1,4})\))?(?:\/T(\d{1,4}))?(?!\w)|\bT(\d{1,4})(?!\w)/g;
20
+ const pad = (n) => String(n).padStart(3, "0");
21
+ function holdEntry(task) {
22
+ const entry = [...(task.statusLog ?? [])].reverse().find((log) => log.toStatus === task.status);
23
+ const reason = (entry?.description ?? "").replace(/^Motivation:\s*/i, "").trim();
24
+ return { reason, at: entry?.date ?? task.updatedAt ?? "" };
25
+ }
26
+ /** Resolve every planner ref named in `text`, by number only (never by title). */
27
+ export function refsNamedIn(text, phases, features, selfTaskId = "") {
28
+ const phaseByNumber = new Map(phases.map((phase) => [phase.number, phase]));
29
+ const taskByNumber = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.number, { phase, task }])));
30
+ const featureByNumber = new Map(features.map((feature) => [feature.number, feature]));
31
+ const seen = new Map();
32
+ for (const match of text.matchAll(REF_IN_TEXT)) {
33
+ const [, leadingFeatureNum, phaseNum, trailingFeatureNum, phaseTaskNum, bareTaskNum] = match;
34
+ const featureNum = trailingFeatureNum ?? leadingFeatureNum;
35
+ if (bareTaskNum || phaseTaskNum) {
36
+ const taskNum = Number(bareTaskNum ?? phaseTaskNum);
37
+ const hit = taskByNumber.get(taskNum);
38
+ const parentOk = !phaseNum || hit?.phase.number === Number(phaseNum);
39
+ const featureOk = !featureNum || (hit && hit.phase.featureId === featureByNumber.get(Number(featureNum))?.id);
40
+ const ref = phaseNum
41
+ ? `P${pad(Number(phaseNum))}${featureNum ? `(F${pad(Number(featureNum))})` : ""}/T${pad(taskNum)}`
42
+ : `T${pad(taskNum)}`;
43
+ if (hit?.task.id === selfTaskId)
44
+ continue;
45
+ const found = Boolean(hit && parentOk && featureOk);
46
+ seen.set(ref, { ref, found, finished: found && FINISHED.has(hit.task.status) });
47
+ continue;
48
+ }
49
+ const phase = phaseByNumber.get(Number(phaseNum));
50
+ const featureOk = !featureNum || (phase && phase.featureId === featureByNumber.get(Number(featureNum))?.id);
51
+ const ref = `P${pad(Number(phaseNum))}${featureNum ? `(F${pad(Number(featureNum))})` : ""}`;
52
+ const found = Boolean(phase && featureOk);
53
+ const finished = found && phase.tasks.length > 0 && phase.tasks.every((task) => FINISHED.has(task.status));
54
+ seen.set(ref, { ref, found, finished });
55
+ }
56
+ return [...seen.values()];
57
+ }
58
+ /**
59
+ * Every on-hold task, ready-to-resume ones first. Pass `phaseIds` to limit
60
+ * the list to the phases a caller is about to act on.
61
+ */
62
+ export function listOnHoldWork(features, phases, phaseIds) {
63
+ const items = [];
64
+ for (const phase of phases) {
65
+ if (phaseIds && !phaseIds.has(phase.id))
66
+ continue;
67
+ const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
68
+ for (const task of phase.tasks) {
69
+ if (!ON_HOLD_STATUSES.has(task.status))
70
+ continue;
71
+ const { reason, at } = holdEntry(task);
72
+ const namedRefs = refsNamedIn(reason, phases, features, task.id);
73
+ items.push({
74
+ taskId: task.id,
75
+ ref: `${phaseRef}/T${pad(task.number)}`,
76
+ title: task.title,
77
+ status: task.status,
78
+ phaseId: phase.id,
79
+ phaseRef,
80
+ reason,
81
+ heldSince: at,
82
+ namedRefs,
83
+ readyToResume: namedRefs.length > 0 && namedRefs.every((named) => named.finished),
84
+ });
85
+ }
86
+ }
87
+ return items.sort((left, right) => Number(right.readyToResume) - Number(left.readyToResume));
88
+ }
89
+ /**
90
+ * The phases whose on-hold work matters now: phases where work has started
91
+ * (some task finished or in progress) but not every task is finished. A
92
+ * phase nobody has started yet keeps its on-hold tasks out of the way.
93
+ */
94
+ export function startedOpenPhaseIds(phases) {
95
+ return new Set(phases
96
+ .filter((phase) => phase.tasks.some((task) => FINISHED.has(task.status) || task.status === "in-progress"))
97
+ .filter((phase) => phase.tasks.some((task) => !FINISHED.has(task.status)))
98
+ .map((phase) => phase.id));
99
+ }
100
+ const REASON_PREVIEW_CHARS = 160;
101
+ /**
102
+ * One bounded text block for any reply. Ready-to-resume tasks first; each
103
+ * line carries the reason so the agent can re-check it against the plan.
104
+ */
105
+ export function renderOnHoldWork(items, limit = 8) {
106
+ if (items.length === 0)
107
+ return "";
108
+ const shown = items.slice(0, limit);
109
+ const lines = shown.map((item) => {
110
+ const reason = item.reason
111
+ ? item.reason.length > REASON_PREVIEW_CHARS ? `${item.reason.slice(0, REASON_PREVIEW_CHARS).trimEnd()}…` : item.reason
112
+ : "(no reason recorded)";
113
+ const check = item.readyToResume
114
+ ? ` — MAY BE READY: everything its reason names is finished (${item.namedRefs.map((named) => named.ref).join(", ")}); re-check and resume it if nothing else holds it`
115
+ : item.namedRefs.some((named) => !named.finished)
116
+ ? ` — still waiting on ${item.namedRefs.filter((named) => !named.finished).map((named) => named.found ? named.ref : `${named.ref} (not found)`).join(", ")}`
117
+ : "";
118
+ return `- ${item.ref} — ${item.title} [${item.status}]${check}\n Reason: ${reason}`;
119
+ });
120
+ const more = items.length > shown.length ? `\n(${items.length - shown.length} more on hold; list them with the phase's task list.)` : "";
121
+ const ready = items.filter((item) => item.readyToResume).length;
122
+ return `On hold — check whether these can resume (${items.length} on hold${ready ? `, ${ready} may be ready` : ""}):\n${lines.join("\n")}${more}`;
123
+ }
@@ -8,4 +8,17 @@ export interface RuntimePackageVersion {
8
8
  export declare function packageVersionFromModule(moduleUrlOrPath: string, expectedName: string): RuntimePackageVersion;
9
9
  /** Resolve a dependency exactly as the caller loaded it, then read its manifest. */
10
10
  export declare function resolvedPackageVersion(packageName: string, fromModuleUrl: string): RuntimePackageVersion;
11
+ /**
12
+ * A long-lived process (an MCP server a session started, a Pi extension)
13
+ * keeps running the code it loaded. After an upgrade it silently runs old
14
+ * code, so an already-fixed bug looks like it came back. This returns a
15
+ * checker that re-reads the loaded package's manifest — one small file, at
16
+ * most once per `intervalMs` — and returns a one-line notice while the
17
+ * version on disk differs from the one loaded. It never throws: an
18
+ * unreadable manifest means no notice.
19
+ */
20
+ export declare function createStaleProcessCheck(loaded: RuntimePackageVersion, { intervalMs, now }?: {
21
+ intervalMs?: number;
22
+ now?: () => number;
23
+ }): () => string | undefined;
11
24
  //# sourceMappingURL=package-version.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"package-version.d.ts","sourceRoot":"","sources":["../src/package-version.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAaD,wEAAwE;AACxE,wBAAgB,wBAAwB,CACtC,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,GACnB,qBAAqB,CAqBvB;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,GACpB,qBAAqB,CAGvB"}
1
+ {"version":3,"file":"package-version.d.ts","sourceRoot":"","sources":["../src/package-version.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAaD,wEAAwE;AACxE,wBAAgB,wBAAwB,CACtC,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,GACnB,qBAAqB,CAqBvB;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,GACpB,qBAAqB,CAGvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,qBAAqB,EAC7B,EAAE,UAAwB,EAAE,GAAsB,EAAE,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACrG,MAAM,MAAM,GAAG,SAAS,CAmB1B"}
@@ -33,3 +33,35 @@ export function resolvedPackageVersion(packageName, fromModuleUrl) {
33
33
  const entryPath = createRequire(fromModuleUrl).resolve(packageName);
34
34
  return packageVersionFromModule(entryPath, packageName);
35
35
  }
36
+ /**
37
+ * A long-lived process (an MCP server a session started, a Pi extension)
38
+ * keeps running the code it loaded. After an upgrade it silently runs old
39
+ * code, so an already-fixed bug looks like it came back. This returns a
40
+ * checker that re-reads the loaded package's manifest — one small file, at
41
+ * most once per `intervalMs` — and returns a one-line notice while the
42
+ * version on disk differs from the one loaded. It never throws: an
43
+ * unreadable manifest means no notice.
44
+ */
45
+ export function createStaleProcessCheck(loaded, { intervalMs = 10 * 60_000, now = () => Date.now() } = {}) {
46
+ let checkedAt = Number.NEGATIVE_INFINITY;
47
+ let notice;
48
+ return () => {
49
+ if (!loaded.packageJsonPath)
50
+ return undefined;
51
+ const time = now();
52
+ if (time - checkedAt < intervalMs)
53
+ return notice;
54
+ checkedAt = time;
55
+ notice = undefined;
56
+ try {
57
+ const manifest = JSON.parse(readFileSync(loaded.packageJsonPath, "utf-8"));
58
+ if (typeof manifest.version === "string" && manifest.version.trim() && manifest.version !== loaded.version) {
59
+ notice = `⚠️ This session is running ${loaded.name} ${loaded.version}, but ${manifest.version} is now installed. Restart the session (in Claude Code: /mcp, then reconnect agent-plan) to use it; until then, fixes made after ${loaded.version} are not active here.`;
60
+ }
61
+ }
62
+ catch {
63
+ notice = undefined;
64
+ }
65
+ return notice;
66
+ };
67
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared list-pagination primitive. Both adapters defined an identical
3
+ * `boundedPage` (page list tools: feature/phase/task/requirement listing,
4
+ * idea listing) with the same 1-25 page-size clamp and the same
5
+ * page-out-of-range clamp. One copy here so the bound is a single decision,
6
+ * not something each adapter must independently re-derive and keep in sync.
7
+ */
8
+ export interface BoundedPage<T> {
9
+ items: T[];
10
+ page: number;
11
+ pageSize: number;
12
+ total: number;
13
+ totalPages: number;
14
+ }
15
+ /** Slice `items` into a bounded page. `pageSize` is clamped to [1, 25];
16
+ * `page` is clamped to [1, totalPages] so an out-of-range request degrades
17
+ * to the nearest valid page instead of an empty result. */
18
+ export declare function boundedPage<T>(items: T[], page: number, pageSize: number): BoundedPage<T>;
19
+ //# sourceMappingURL=pagination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;4DAE4D;AAC5D,wBAAgB,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,CAWzF"}
@@ -0,0 +1,15 @@
1
+ /** Slice `items` into a bounded page. `pageSize` is clamped to [1, 25];
2
+ * `page` is clamped to [1, totalPages] so an out-of-range request degrades
3
+ * to the nearest valid page instead of an empty result. */
4
+ export function boundedPage(items, page, pageSize) {
5
+ const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
6
+ const totalPages = Math.max(1, Math.ceil(items.length / size));
7
+ const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
8
+ return {
9
+ items: items.slice((currentPage - 1) * size, currentPage * size),
10
+ page: currentPage,
11
+ pageSize: size,
12
+ total: items.length,
13
+ totalPages,
14
+ };
15
+ }
@@ -8,7 +8,7 @@ export function isPlannerDescriptionRef(value) {
8
8
  if (value.includes("\\") || value.includes("\0"))
9
9
  return false;
10
10
  const parts = value.split("/");
11
- if (parts.length < 4)
11
+ if (parts.length < 3)
12
12
  return false;
13
13
  if (parts[0] !== ".planner" || parts[1] !== "docs")
14
14
  return false;