@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,94 @@
1
+ /**
2
+ * Accepted Decision ownership rule and the compatibility guards that enforce
3
+ * it, shared by every adapter (MCP, Pi, HTTP, and any future harness).
4
+ *
5
+ * The rule: a decision has exactly one owner. Project-wide decisions belong
6
+ * only to project.acceptedDecisions; feature-wide decisions only to that
7
+ * feature; phase-specific decisions only to that phase; genuinely
8
+ * task-local decisions only to that task. Nothing duplicates a decision
9
+ * across two owners by default — see the semantic createAcceptedDecision /
10
+ * updateAcceptedDecision / deleteAcceptedDecision operations in
11
+ * plan-store.ts, which already write to exactly one target.
12
+ *
13
+ * Two things previously worked against that rule, both fixed here once so
14
+ * no adapter has to restate the wording (AGENTS.md rule 4):
15
+ *
16
+ * 1. `decision_record` (Pi) wrote the same decision onto both a feature and
17
+ * a phase. It no longer writes anything; see
18
+ * buildDecisionRecordRedirectReply.
19
+ * 2. The legacy free-form `decisions: string[]` field on project, phase,
20
+ * and task let an agent keep "recording" decisions in a place nothing
21
+ * treats as authoritative (buildBoundedAcceptedDecisionContext only ever
22
+ * reads acceptedDecisions). Every update tool that exposes this field
23
+ * now rejects new writes to it with LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE,
24
+ * the same way raw acceptedDecisions replacement was already rejected.
25
+ * Existing legacy content is left exactly as persisted — read-only, not
26
+ * deleted or migrated; only project-level legacy decisions get folded
27
+ * into acceptedDecisions, and only through the existing explicit
28
+ * project-context migration, never automatically here.
29
+ */
30
+ import { type PlanStore } from "./plan-store.js";
31
+ import type { MutationReply } from "./mutation-reply.js";
32
+ export declare const ACCEPTED_DECISION_OWNERSHIP_RULE = "A decision has exactly one owner: the project when it is project-wide, a feature when it is feature-wide, a phase when it is phase-specific, or a task when the decision is genuinely local to that task's work and will not outlive it. Never record the same decision on more than one owner.";
33
+ export declare const ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE = "Raw acceptedDecisions replacement is disabled. Use the accepted-decision create, update, or delete operations (accepted_decision_create/update/delete, planner-accepted-decision-create/update/delete, or the /accepted-decisions HTTP endpoints) so IDs and acceptedAt are preserved.";
34
+ export declare const LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE = "The legacy free-form decisions list is retained read-only for history; it is not decision authority. A decision has exactly one owner: the project when it is project-wide, a feature when it is feature-wide, a phase when it is phase-specific, or a task when the decision is genuinely local to that task's work and will not outlive it. Never record the same decision on more than one owner. Record new durable decisions with the accepted-decision create operation on the owner that actually holds this one.";
35
+ export declare const ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE = "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED";
36
+ export declare const LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE = "LEGACY_DECISIONS_ARRAY_READ_ONLY";
37
+ /**
38
+ * decision_record's compatibility reply: a typed redirect, never a write.
39
+ * It cannot dual-write because it performs no write at all — the caller
40
+ * must pick exactly one owner and call the semantic create operation
41
+ * itself. Kept here so every current or future surface offering a
42
+ * decision_record-shaped compatibility tool renders identical guidance
43
+ * instead of drifting.
44
+ */
45
+ export interface DecisionRecordRedirectInput {
46
+ /** Resolved, human-readable feature ref (e.g. "F005"), for the feature suggestion. */
47
+ featureRef: string;
48
+ /** Resolved, human-readable phase ref (e.g. "P102(F005)"), for the phase suggestion. */
49
+ phaseRef: string;
50
+ }
51
+ export interface DecisionRecordRedirectReply {
52
+ text: string;
53
+ structured: Record<string, unknown>;
54
+ }
55
+ export declare function buildDecisionRecordRedirectReply(input: DecisionRecordRedirectInput): DecisionRecordRedirectReply;
56
+ /**
57
+ * Resolve an Accepted Decision target (project/feature/phase/task) to its
58
+ * owning entity id and a display ref. Both adapters carried an identical
59
+ * copy for accepted_decision_create/update/delete; kept here so target
60
+ * resolution can never drift from `resolveFeatureRefStrict`/
61
+ * `findPhaseByRef`/`findTaskByRef` — the same ref grammar this project uses
62
+ * everywhere else.
63
+ */
64
+ export type AcceptedDecisionTargetType = "project" | "feature" | "phase" | "task";
65
+ export type AcceptedDecisionTargetResolution = {
66
+ ok: true;
67
+ owner: {
68
+ kind: "project";
69
+ } | {
70
+ kind: "feature";
71
+ featureId: string;
72
+ } | {
73
+ kind: "phase";
74
+ phaseId: string;
75
+ } | {
76
+ kind: "task";
77
+ phaseId: string;
78
+ taskId: string;
79
+ };
80
+ ref: string;
81
+ } | {
82
+ ok: false;
83
+ error: string;
84
+ };
85
+ export declare function resolveAcceptedDecisionTarget(st: PlanStore, targetType: AcceptedDecisionTargetType, targetRef: string | undefined): Promise<AcceptedDecisionTargetResolution>;
86
+ /**
87
+ * Render a PlanStoreError raised by an Accepted Decision create/update/delete
88
+ * as a text/structured pair, or rethrow when it isn't one of those (a
89
+ * different failure is the caller's to handle). Both adapters carried an
90
+ * identical copy of this decision; kept here so the type check and the
91
+ * message can never drift. Each adapter still wraps its own envelope.
92
+ */
93
+ export declare function acceptedDecisionMutationFailureReply(error: unknown, outcome: "created" | "updated" | "deleted"): MutationReply;
94
+ //# sourceMappingURL=accepted-decision-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accepted-decision-guard.d.ts","sourceRoot":"","sources":["../src/accepted-decision-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAGjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,eAAO,MAAM,gCAAgC,oSACsP,CAAC;AAEpS,eAAO,MAAM,kDAAkD,2RAC2N,CAAC;AAE3R,eAAO,MAAM,wCAAwC,6fAC2M,CAAC;AAEjQ,eAAO,MAAM,uDAAuD,iDAAiD,CAAC;AACtH,eAAO,MAAM,2CAA2C,qCAAqC,CAAC;AAE9F;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA2B;IAC1C,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,2BAA2B,GAAG,2BAA2B,CA0BhH;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;AAClF,MAAM,MAAM,gCAAgC,GACxC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,SAAS,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACvL;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjC,wBAAsB,6BAA6B,CACjD,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,0BAA0B,EACtC,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,OAAO,CAAC,gCAAgC,CAAC,CA0B3C;AAED;;;;;;GAMG;AACH,wBAAgB,oCAAoC,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAM9H"}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Accepted Decision ownership rule and the compatibility guards that enforce
3
+ * it, shared by every adapter (MCP, Pi, HTTP, and any future harness).
4
+ *
5
+ * The rule: a decision has exactly one owner. Project-wide decisions belong
6
+ * only to project.acceptedDecisions; feature-wide decisions only to that
7
+ * feature; phase-specific decisions only to that phase; genuinely
8
+ * task-local decisions only to that task. Nothing duplicates a decision
9
+ * across two owners by default — see the semantic createAcceptedDecision /
10
+ * updateAcceptedDecision / deleteAcceptedDecision operations in
11
+ * plan-store.ts, which already write to exactly one target.
12
+ *
13
+ * Two things previously worked against that rule, both fixed here once so
14
+ * no adapter has to restate the wording (AGENTS.md rule 4):
15
+ *
16
+ * 1. `decision_record` (Pi) wrote the same decision onto both a feature and
17
+ * a phase. It no longer writes anything; see
18
+ * buildDecisionRecordRedirectReply.
19
+ * 2. The legacy free-form `decisions: string[]` field on project, phase,
20
+ * and task let an agent keep "recording" decisions in a place nothing
21
+ * treats as authoritative (buildBoundedAcceptedDecisionContext only ever
22
+ * reads acceptedDecisions). Every update tool that exposes this field
23
+ * now rejects new writes to it with LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE,
24
+ * the same way raw acceptedDecisions replacement was already rejected.
25
+ * Existing legacy content is left exactly as persisted — read-only, not
26
+ * deleted or migrated; only project-level legacy decisions get folded
27
+ * into acceptedDecisions, and only through the existing explicit
28
+ * project-context migration, never automatically here.
29
+ */
30
+ import { PlanStoreError } from "./plan-store.js";
31
+ import { formatFeatureRef, formatPhaseRef, featureNumberOfPhase } from "./naming.js";
32
+ import { findPhaseByRef, findTaskByRef, resolveFeatureRefStrict } from "./refs.js";
33
+ export const ACCEPTED_DECISION_OWNERSHIP_RULE = "A decision has exactly one owner: the project when it is project-wide, a feature when it is feature-wide, a phase when it is phase-specific, or a task when the decision is genuinely local to that task's work and will not outlive it. Never record the same decision on more than one owner.";
34
+ export const ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE = "Raw acceptedDecisions replacement is disabled. Use the accepted-decision create, update, or delete operations (accepted_decision_create/update/delete, planner-accepted-decision-create/update/delete, or the /accepted-decisions HTTP endpoints) so IDs and acceptedAt are preserved.";
35
+ export const LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE = `The legacy free-form decisions list is retained read-only for history; it is not decision authority. ${ACCEPTED_DECISION_OWNERSHIP_RULE} Record new durable decisions with the accepted-decision create operation on the owner that actually holds this one.`;
36
+ export const ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE = "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED";
37
+ export const LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE = "LEGACY_DECISIONS_ARRAY_READ_ONLY";
38
+ export function buildDecisionRecordRedirectReply(input) {
39
+ const { featureRef, phaseRef } = input;
40
+ return {
41
+ text: [
42
+ `decision_record no longer writes a decision on both ${featureRef} and ${phaseRef}.`,
43
+ ACCEPTED_DECISION_OWNERSHIP_RULE,
44
+ "Call accepted_decision_create with the single owner this decision actually belongs to:",
45
+ `- Project-wide: targetType "project" (omit targetRef).`,
46
+ `- Feature-wide: targetType "feature", targetRef "${featureRef}".`,
47
+ `- Phase-specific: targetType "phase", targetRef "${phaseRef}".`,
48
+ `- Task-local: targetType "task", targetRef the exact task composite ref.`,
49
+ ].join("\n"),
50
+ structured: {
51
+ recorded: false,
52
+ written: false,
53
+ redirected: true,
54
+ redirectTool: "accepted_decision_create",
55
+ ownershipRule: ACCEPTED_DECISION_OWNERSHIP_RULE,
56
+ suggestions: {
57
+ project: { targetType: "project" },
58
+ feature: { targetType: "feature", targetRef: featureRef },
59
+ phase: { targetType: "phase", targetRef: phaseRef },
60
+ task: { targetType: "task", targetRef: null },
61
+ },
62
+ },
63
+ };
64
+ }
65
+ export async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
66
+ if (targetType === "project") {
67
+ const project = await st.loadProject();
68
+ return { ok: true, owner: { kind: "project" }, ref: project.name };
69
+ }
70
+ const ref = targetRef?.trim();
71
+ if (!ref)
72
+ return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
73
+ const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
74
+ const features = featuresDoc.features;
75
+ if (targetType === "feature") {
76
+ const resolved = resolveFeatureRefStrict(features, ref);
77
+ if (!resolved.ok)
78
+ return { ok: false, error: resolved.error };
79
+ return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
80
+ }
81
+ if (targetType === "phase") {
82
+ const phase = findPhaseByRef(phases, features, ref);
83
+ if (!phase)
84
+ return { ok: false, error: `Phase not found: ${ref}` };
85
+ return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
86
+ }
87
+ const found = findTaskByRef(phases, features, ref);
88
+ if (!found)
89
+ return { ok: false, error: `Task not found: ${ref}` };
90
+ return {
91
+ ok: true,
92
+ owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id },
93
+ ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(found.task.number).padStart(3, "0")}`,
94
+ };
95
+ }
96
+ /**
97
+ * Render a PlanStoreError raised by an Accepted Decision create/update/delete
98
+ * as a text/structured pair, or rethrow when it isn't one of those (a
99
+ * different failure is the caller's to handle). Both adapters carried an
100
+ * identical copy of this decision; kept here so the type check and the
101
+ * message can never drift. Each adapter still wraps its own envelope.
102
+ */
103
+ export function acceptedDecisionMutationFailureReply(error, outcome) {
104
+ if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
105
+ throw error;
106
+ return {
107
+ text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}`,
108
+ structured: { [outcome]: false, ...error.details },
109
+ };
110
+ }
@@ -13,4 +13,32 @@ export declare function addChecklistItem(items: ChecklistItem[], taskId: string,
13
13
  export declare function removeChecklistItem(items: ChecklistItem[], selector: string): ChecklistItem | undefined;
14
14
  /** Tick/untick the matched item in place. checked omitted → toggle. Returns the item, or undefined. */
15
15
  export declare function toggleChecklistItem(items: ChecklistItem[], selector: string, checked?: boolean): ChecklistItem | undefined;
16
+ export interface ChecklistReplacement {
17
+ items: ChecklistItem[];
18
+ /** Titles of previously-ticked items whose tick could not be carried over.
19
+ * Empty on the common paths. Callers MUST surface a non-empty list: a
20
+ * success result that drops ticks without saying so is the defect this
21
+ * function exists to prevent. */
22
+ lostTicks: string[];
23
+ }
24
+ /**
25
+ * Build the replacement list for a whole-checklist update, carrying tick state
26
+ * across from the items being replaced.
27
+ *
28
+ * The input is plain titles, so item identity has to be reconstructed. Two
29
+ * passes, in this order:
30
+ *
31
+ * 1. Exact trimmed title. Order-independent, so reordering a list keeps every
32
+ * tick. Each surviving item is consumed at most once, so duplicate titles
33
+ * carry over one tick each rather than multiplying.
34
+ * 2. Position, but ONLY when the list length is unchanged. This is what makes
35
+ * renaming an item keep its tick — the reported failure case. Restricting
36
+ * it to equal-length lists means an insertion or deletion never shifts a
37
+ * tick onto a neighbour.
38
+ *
39
+ * Anything still unmatched is a new item and starts unchecked; if it displaced
40
+ * a ticked item, that title is reported in `lostTicks` rather than dropped in
41
+ * silence.
42
+ */
43
+ export declare function replaceChecklist(existing: ChecklistItem[], titles: string[], taskId: string): ChecklistReplacement;
16
44
  //# sourceMappingURL=checklist.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"checklist.d.ts","sourceRoot":"","sources":["../src/checklist.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;GAMG;AAEH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAerG;AAED,iHAAiH;AACjH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,CAKrG;AAED,oHAAoH;AACpH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CASvG;AAED,uGAAuG;AACvG,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS,CAK1H"}
1
+ {"version":3,"file":"checklist.d.ts","sourceRoot":"","sources":["../src/checklist.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;GAMG;AAEH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAerG;AAED,iHAAiH;AACjH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,CAKrG;AAED,oHAAoH;AACpH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CASvG;AAED,uGAAuG;AACvG,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS,CAK1H;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB;;;sCAGkC;IAClC,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,oBAAoB,CA0ClH"}
package/dist/checklist.js CHANGED
@@ -50,3 +50,62 @@ export function toggleChecklistItem(items, selector, checked) {
50
50
  found.checked = checked ?? !found.checked;
51
51
  return found;
52
52
  }
53
+ /**
54
+ * Build the replacement list for a whole-checklist update, carrying tick state
55
+ * across from the items being replaced.
56
+ *
57
+ * The input is plain titles, so item identity has to be reconstructed. Two
58
+ * passes, in this order:
59
+ *
60
+ * 1. Exact trimmed title. Order-independent, so reordering a list keeps every
61
+ * tick. Each surviving item is consumed at most once, so duplicate titles
62
+ * carry over one tick each rather than multiplying.
63
+ * 2. Position, but ONLY when the list length is unchanged. This is what makes
64
+ * renaming an item keep its tick — the reported failure case. Restricting
65
+ * it to equal-length lists means an insertion or deletion never shifts a
66
+ * tick onto a neighbour.
67
+ *
68
+ * Anything still unmatched is a new item and starts unchecked; if it displaced
69
+ * a ticked item, that title is reported in `lostTicks` rather than dropped in
70
+ * silence.
71
+ */
72
+ export function replaceChecklist(existing, titles, taskId) {
73
+ const cleanTitles = titles.map((title) => title.trim()).filter((title) => title.length > 0);
74
+ const consumed = new Set();
75
+ const carried = new Array(cleanTitles.length).fill(false);
76
+ // Tracks whether pass 1 found an exact-title match, independent of that
77
+ // match's checked value. `carried[index]` alone cannot stand in for this:
78
+ // an exact match to an unticked item legitimately produces carried=false,
79
+ // and pass 2 must not treat that as "unmatched" and overwrite it with a
80
+ // stray tick from whatever unrelated item previously sat at this position.
81
+ const matchedByTitle = new Array(cleanTitles.length).fill(false);
82
+ cleanTitles.forEach((title, index) => {
83
+ const match = existing.findIndex((item, i) => !consumed.has(i) && item.title.trim() === title);
84
+ if (match >= 0) {
85
+ consumed.add(match);
86
+ matchedByTitle[index] = true;
87
+ carried[index] = existing[match].checked;
88
+ }
89
+ });
90
+ if (cleanTitles.length === existing.length) {
91
+ cleanTitles.forEach((_title, index) => {
92
+ if (matchedByTitle[index])
93
+ return;
94
+ const candidate = existing[index];
95
+ if (!candidate || consumed.has(index))
96
+ return;
97
+ consumed.add(index);
98
+ carried[index] = candidate.checked;
99
+ });
100
+ }
101
+ const items = cleanTitles.map((title, index) => ({
102
+ id: createChecklistItemId(taskId, index + 1, title),
103
+ number: index + 1,
104
+ title,
105
+ checked: carried[index] ?? false,
106
+ }));
107
+ const lostTicks = existing
108
+ .filter((item, i) => item.checked && !consumed.has(i))
109
+ .map((item) => item.title);
110
+ return { items, lostTicks };
111
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Delete a feature the same way from every surface — MCP, the pi adapter's
3
+ * tool and interactive paths, and the REST server. Before this task each of
4
+ * those four wrote its own delete, and two of them cleared the feature
5
+ * record and never touched its phases: the phases were left pointing at a
6
+ * featureId that no longer existed anywhere — an orphan, not a link. Per
7
+ * AGENTS.md rule 4, "also unlink the phases" is not a rule a caller should
8
+ * have to remember; it lives here once and every caller gets it whether it
9
+ * asked for it or not.
10
+ *
11
+ * Cascade semantics (must match exactly — do not "simplify" to one
12
+ * behaviour): `cascade: true` deletes the feature's phases outright (phase
13
+ * deletion already archives their handoffs; this does not reimplement
14
+ * that). `cascade: false` unlinks each phase (clears `featureId`) and
15
+ * leaves it alive — the phase still exists with its tasks, it just no
16
+ * longer belongs to a feature.
17
+ *
18
+ * The delete is verified, not assumed: after writing, the feature list is
19
+ * read back from disk and the id must actually be gone. Reporting success
20
+ * without checking is the same defect class as T413.
21
+ */
22
+ import type { PlanStore } from "./plan-store.js";
23
+ export interface DeleteFeatureCascadeOptions {
24
+ /** true deletes the feature's phases; false unlinks them and leaves them alive. */
25
+ cascade: boolean;
26
+ }
27
+ /** One phase's fate, for callers (the REST server's WS broadcast, in
28
+ * particular) that need to say what happened to each affected phase. */
29
+ export interface DeleteFeatureCascadePhase {
30
+ id: string;
31
+ action: "deleted" | "unlinked";
32
+ }
33
+ export interface DeleteFeatureCascadeResult {
34
+ /** Canonical F00x ref — never the caller's raw input. */
35
+ ref: string;
36
+ id: string;
37
+ name: string;
38
+ cascade: boolean;
39
+ phaseCount: number;
40
+ phases: DeleteFeatureCascadePhase[];
41
+ }
42
+ export type DeleteFeatureCascadeErrorCode = "FEATURE_NOT_FOUND" | "FEATURE_DELETE_NOT_PERSISTED";
43
+ export type DeleteFeatureCascadeOutcome = {
44
+ ok: true;
45
+ result: DeleteFeatureCascadeResult;
46
+ } | {
47
+ ok: false;
48
+ errorCode: DeleteFeatureCascadeErrorCode;
49
+ error: string;
50
+ };
51
+ /**
52
+ * Delete the feature identified by the already-resolved `featureId` (a
53
+ * canonical UUID — turning a caller's fuzzy ref like "checkout" or "F5"
54
+ * into that id is the caller's job, same as every other mutation in this
55
+ * codebase; resolveFeatureRefStrict does that today). The feature is
56
+ * re-read fresh, inside the write lock, immediately before the mutation —
57
+ * not trusted from whatever the caller resolved earlier — so a feature
58
+ * that another process deleted between the caller's resolve and this call
59
+ * fails loudly (FEATURE_NOT_FOUND) instead of reporting a phantom success.
60
+ */
61
+ export declare function deleteFeatureCascade(store: PlanStore, featureId: string, options: DeleteFeatureCascadeOptions): Promise<DeleteFeatureCascadeOutcome>;
62
+ //# sourceMappingURL=feature-delete.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feature-delete.d.ts","sourceRoot":"","sources":["../src/feature-delete.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAGjD,MAAM,WAAW,2BAA2B;IAC1C,mFAAmF;IACnF,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;wEACwE;AACxE,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,UAAU,CAAC;CAChC;AAED,MAAM,WAAW,0BAA0B;IACzC,yDAAyD;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,yBAAyB,EAAE,CAAC;CACrC;AAED,MAAM,MAAM,6BAA6B,GAAG,mBAAmB,GAAG,8BAA8B,CAAC;AAEjG,MAAM,MAAM,2BAA2B,GACnC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,0BAA0B,CAAA;CAAE,GAChD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,6BAA6B,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3E;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,SAAS,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,2BAA2B,GACnC,OAAO,CAAC,2BAA2B,CAAC,CAyCtC"}
@@ -0,0 +1,49 @@
1
+ import { formatFeatureRef } from "./naming.js";
2
+ /**
3
+ * Delete the feature identified by the already-resolved `featureId` (a
4
+ * canonical UUID — turning a caller's fuzzy ref like "checkout" or "F5"
5
+ * into that id is the caller's job, same as every other mutation in this
6
+ * codebase; resolveFeatureRefStrict does that today). The feature is
7
+ * re-read fresh, inside the write lock, immediately before the mutation —
8
+ * not trusted from whatever the caller resolved earlier — so a feature
9
+ * that another process deleted between the caller's resolve and this call
10
+ * fails loudly (FEATURE_NOT_FOUND) instead of reporting a phantom success.
11
+ */
12
+ export async function deleteFeatureCascade(store, featureId, options) {
13
+ return store.runBatch(async () => {
14
+ const features = (await store.loadFeatures()).features;
15
+ const feature = features.find((entry) => entry.id === featureId);
16
+ if (!feature) {
17
+ return { ok: false, errorCode: "FEATURE_NOT_FOUND", error: `Feature not found: ${featureId}` };
18
+ }
19
+ const ref = formatFeatureRef(feature.number);
20
+ const phases = (await store.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
21
+ await store.updateFeatures((doc) => {
22
+ doc.features = doc.features.filter((entry) => entry.id !== feature.id);
23
+ return doc;
24
+ });
25
+ const phaseResults = [];
26
+ for (const phase of phases) {
27
+ if (options.cascade) {
28
+ await store.deletePhase(phase.id);
29
+ phaseResults.push({ id: phase.id, action: "deleted" });
30
+ }
31
+ else {
32
+ await store.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: new Date().toISOString() }));
33
+ phaseResults.push({ id: phase.id, action: "unlinked" });
34
+ }
35
+ }
36
+ await store.writeGenerated();
37
+ // Prove it happened: the feature must actually be gone from a fresh
38
+ // disk read, not merely filtered out of an in-memory copy that a
39
+ // subsequent write silently dropped.
40
+ const stillPresent = (await store.loadFeatures()).features.some((entry) => entry.id === feature.id);
41
+ if (stillPresent) {
42
+ return { ok: false, errorCode: "FEATURE_DELETE_NOT_PERSISTED", error: `Feature delete did not persist: ${ref}` };
43
+ }
44
+ return {
45
+ ok: true,
46
+ result: { ref, id: feature.id, name: feature.name, cascade: options.cascade, phaseCount: phaseResults.length, phases: phaseResults },
47
+ };
48
+ });
49
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Harness-agnostic logic for the "no task in progress" warning. The guard
3
+ * never blocks and never asks: every tool call proceeds, and a call that
4
+ * changes project code while no task is in progress only earns the agent a
5
+ * warning. Planner operations never need a task. Any adapter (Claude Code
6
+ * PreToolUse hook, Pi's tool_call handler, a future harness) feeds it a
7
+ * parsed tool-use event plus already-fetched planner state and gets back the
8
+ * same warning, or none. This never talks to the plan store
9
+ * or stdin/stdout — callers own that I/O. It does read the filesystem in one
10
+ * narrow way (see {@link canonicalize}): resolving symlinks so a path
11
+ * comparison isn't fooled by one, which is cheap (a handful of stat calls,
12
+ * not a plan load) and safe to call on paths that don't exist yet.
13
+ */
14
+ /** The tool-use fields the decision needs, already pulled out of whatever
15
+ * shape the harness's raw event uses (Claude Code's `tool_input.file_path`,
16
+ * Pi's `event.input.path`, etc.). */
17
+ export interface GuardEventInput {
18
+ /** Tool name in any case: Claude Code's `Edit`/`Bash` and Pi's `edit`/`bash` are the same tool. */
19
+ toolName: string;
20
+ /** Absolute (or resolvable) working directory used to resolve relative paths. */
21
+ cwd: string;
22
+ /** Absolute planner root, e.g. `join(cwd, ".planner")`. */
23
+ plannerRoot: string;
24
+ /** Edit / Write target path (Pi: `path`). */
25
+ filePath?: string;
26
+ /** NotebookEdit target path. */
27
+ notebookPath?: string;
28
+ /** Bash command line, when toolName is "Bash". */
29
+ command?: string;
30
+ }
31
+ /** Planner state the caller already fetched (I/O happens once, upstream). */
32
+ export interface GuardStateInput {
33
+ hasPlannerDir: boolean;
34
+ totalTasks: number;
35
+ hasInProgressTask: boolean;
36
+ guardBypassed: boolean;
37
+ /** The task the warning suggests starting, when there is an obvious one. */
38
+ focusTask?: {
39
+ id: string;
40
+ title: string;
41
+ };
42
+ }
43
+ export interface GuardClassification {
44
+ /** Whether this tool call may write project code, so the guard must look at task state. */
45
+ guarded: boolean;
46
+ /** The project paths outside `.planner/` the call would write to; empty when the target is unknown. */
47
+ paths: string[];
48
+ }
49
+ export interface NoTaskWarning {
50
+ /** Text for the agent, present only when it should be warned. */
51
+ warning?: string;
52
+ }
53
+ /**
54
+ * Whether a Bash command line contains a write-shaped operation: output
55
+ * redirection, `tee`, in-place `sed`/`perl`, a file-creating `cp`/`mv`,
56
+ * `rm`, or a `git` operation that rewrites tracked files. Deliberately
57
+ * over-inclusive: `git`, build/test runners, `ls`, `cat`, `grep`, `find`
58
+ * and friends must stay unmatched, but when in doubt this returns true — a
59
+ * false positive costs one warning to the agent, never a blocked call or a
60
+ * prompt, so under-matching (missing a real edit) is the worse failure.
61
+ */
62
+ export declare function isWriteShapedBashCommand(command: string): boolean;
63
+ /**
64
+ * Best-effort extraction of the file path(s) a write-shaped Bash segment
65
+ * targets, so a command that only touches `.planner/` or files outside the
66
+ * project earns no warning. This cannot see through a heredoc whose target
67
+ * is computed (a variable, command substitution, etc.) rather than written
68
+ * literally in the command line — that case still reaches the in-progress /
69
+ * bypass checks and can warn, rather than being silently treated as safe.
70
+ *
71
+ * A wider gap of the same kind: a command that writes through an
72
+ * interpreter (`python3 - <<PY` then `open(path, "w")`, `node -e`, a script
73
+ * invoked by name) is opaque here — nothing in the command line looks
74
+ * write-shaped. P105(F005)/T431 evaluated closing this and chose not to,
75
+ * for reasons that also rule out revisiting it the same way later:
76
+ * - Parsing the script body is unbounded (a path can be computed, read
77
+ * from argv, or built at runtime) and would trade an honest documented
78
+ * limit for false confidence.
79
+ * - A PostToolUse hook that notices tracked files changed while no task
80
+ * was in progress sounds like a fit (the guard's job is visibility, not
81
+ * prevention), but it collides with this project's own workflow: a
82
+ * task is left with its changes uncommitted for the orchestrator to
83
+ * review, so "no task in-progress, tracked files changed outside
84
+ * .planner/" is the routine state right after every task completes,
85
+ * not a signal of anything wrong. Telling that apart from a real
86
+ * interpreter write means diffing only the delta from one specific
87
+ * Bash call, which needs a session-scoped git-status baseline checked
88
+ * on every Bash invocation — read-only ones included, since which
89
+ * commands wrote can't be known in advance. That statefulness and
90
+ * per-call cost is worse than the gap it would close.
91
+ * The limit stands, documented rather than silently assumed away.
92
+ */
93
+ export declare function extractBashWriteTargets(command: string): string[];
94
+ /** Whether `candidate` resolves to a path inside `plannerRoot` (or is it exactly). */
95
+ export declare function isPathInsidePlannerRoot(candidate: string, plannerRoot: string, cwd: string): boolean;
96
+ /** Whether writing `candidate` changes the project's code: it is inside the
97
+ * project (the directory that owns `.planner/`) and not inside `.planner/`.
98
+ * `/dev/null`, `~/.claude/...`, temp dirs and other repositories are not. */
99
+ export declare function isProjectCodePath(candidate: string, plannerRoot: string, cwd: string): boolean;
100
+ /**
101
+ * Whether a tool call may change the project's code, and so needs the guard
102
+ * to look at task state. This is the one place that rule lives; the hook's
103
+ * cheap early exit and {@link noTaskWarning} both read `guarded`.
104
+ *
105
+ * - Tools that never write (Read, Grep, MCP tools, read-only Bash, ...) are
106
+ * not guarded.
107
+ * - A write whose targets are all known and none is project code (inside
108
+ * `.planner/`, `/dev/null`, `~/.claude/...`, a temp dir, another repo) is
109
+ * not guarded: updating the planner or the user's own config never needs a
110
+ * task.
111
+ * - A write with at least one project-code target is guarded, with `paths`
112
+ * narrowed to those targets.
113
+ * - A write whose target is unknown (a computed heredoc, an Edit with no
114
+ * path) is guarded with empty `paths` — never silently allowed.
115
+ */
116
+ export declare function classifyGuardedTool(event: GuardEventInput): GuardClassification;
117
+ /**
118
+ * The warning for a tool call that changes project code while no task is in
119
+ * progress, or none. Never a block or a prompt: the adapter lets the call
120
+ * proceed either way and only passes the text to the agent. Pure apart from
121
+ * the cheap symlink canonicalization in {@link isPathInsidePlannerRoot}.
122
+ *
123
+ * Order mirrors the cost of finding out: anything that cannot change project
124
+ * code (see {@link classifyGuardedTool}) is settled from the event alone, so
125
+ * adapters can skip loading the plan for it.
126
+ */
127
+ export declare function noTaskWarning(event: GuardEventInput, state: GuardStateInput): NoTaskWarning;
128
+ //# sourceMappingURL=guard-decision.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-decision.d.ts","sourceRoot":"","sources":["../src/guard-decision.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH;;qCAEqC;AACrC,MAAM,WAAW,eAAe;IAC9B,mGAAmG;IACnG,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,WAAW,EAAE,MAAM,CAAC;IACpB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,OAAO,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,aAAa,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,SAAS,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,MAAM,WAAW,mBAAmB;IAClC,2FAA2F;IAC3F,OAAO,EAAE,OAAO,CAAC;IACjB,uGAAuG;IACvG,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAiFD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA2BjE;AAuCD,sFAAsF;AACtF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAEpG;AAED;;6EAE6E;AAC7E,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9F;AAoBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,eAAe,GAAG,mBAAmB,CAM/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,eAAe,GAAG,aAAa,CAa3F"}