@agent-plan/core 0.2.29 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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 +118 -0
  11. package/dist/guard-decision.d.ts.map +1 -0
  12. package/dist/guard-decision.js +203 -0
  13. package/dist/handoff-context.d.ts +130 -1
  14. package/dist/handoff-context.d.ts.map +1 -1
  15. package/dist/handoff-context.js +167 -9
  16. package/dist/handoff-reply.d.ts +108 -0
  17. package/dist/handoff-reply.d.ts.map +1 -0
  18. package/dist/handoff-reply.js +279 -0
  19. package/dist/index.d.ts +15 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -1
  22. package/dist/mutation-reply.d.ts +105 -0
  23. package/dist/mutation-reply.d.ts.map +1 -0
  24. package/dist/mutation-reply.js +203 -0
  25. package/dist/naming.d.ts +1 -1
  26. package/dist/naming.d.ts.map +1 -1
  27. package/dist/naming.js +1 -1
  28. package/dist/pagination.d.ts +19 -0
  29. package/dist/pagination.d.ts.map +1 -0
  30. package/dist/pagination.js +15 -0
  31. package/dist/payload-fallback.js +1 -1
  32. package/dist/plan-store.d.ts +141 -8
  33. package/dist/plan-store.d.ts.map +1 -1
  34. package/dist/plan-store.js +459 -87
  35. package/dist/project-context-migration.d.ts +5 -0
  36. package/dist/project-context-migration.d.ts.map +1 -1
  37. package/dist/project-context-migration.js +13 -0
  38. package/dist/project-context.d.ts +395 -0
  39. package/dist/project-context.d.ts.map +1 -0
  40. package/dist/project-context.js +295 -0
  41. package/dist/recap.d.ts.map +1 -1
  42. package/dist/recap.js +27 -0
  43. package/dist/recommend-reply.d.ts +51 -0
  44. package/dist/recommend-reply.d.ts.map +1 -0
  45. package/dist/recommend-reply.js +84 -0
  46. package/dist/refs.d.ts +16 -0
  47. package/dist/refs.d.ts.map +1 -1
  48. package/dist/refs.js +38 -0
  49. package/dist/renderer.d.ts.map +1 -1
  50. package/dist/schema.d.ts +7 -2
  51. package/dist/schema.d.ts.map +1 -1
  52. package/dist/schema.js +8 -3
  53. package/dist/stranded-phase.d.ts +73 -0
  54. package/dist/stranded-phase.d.ts.map +1 -0
  55. package/dist/stranded-phase.js +93 -0
  56. package/dist/task-completion-gate.d.ts +84 -0
  57. package/dist/task-completion-gate.d.ts.map +1 -0
  58. package/dist/task-completion-gate.js +72 -0
  59. package/dist/task-context.d.ts +1 -0
  60. package/dist/task-context.d.ts.map +1 -1
  61. package/dist/task-context.js +31 -13
  62. package/dist/task-lifecycle.d.ts +12 -0
  63. package/dist/task-lifecycle.d.ts.map +1 -0
  64. package/dist/task-lifecycle.js +23 -0
  65. package/dist/task-order-context.d.ts +72 -0
  66. package/dist/task-order-context.d.ts.map +1 -0
  67. package/dist/task-order-context.js +82 -0
  68. package/dist/task-selection.d.ts.map +1 -1
  69. package/dist/task-selection.js +14 -12
  70. package/dist/task-start-outcome.d.ts +26 -0
  71. package/dist/task-start-outcome.d.ts.map +1 -1
  72. package/dist/task-start-outcome.js +23 -0
  73. package/dist/text-bounds.d.ts +32 -0
  74. package/dist/text-bounds.d.ts.map +1 -0
  75. package/dist/text-bounds.js +43 -0
  76. package/dist/write-coordination.d.ts +2 -2
  77. package/dist/write-coordination.js +2 -2
  78. package/package.json +1 -1
  79. 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,118 @@
1
+ /**
2
+ * Harness-agnostic decision logic for the "no task in progress" edit guard.
3
+ * Any adapter (Claude Code PreToolUse hook, Pi's advisory guard, a future
4
+ * harness) can feed it a parsed tool-use event plus already-fetched planner
5
+ * state and get back the same decision. This never talks to the plan store
6
+ * or stdin/stdout — callers own that I/O. It does read the filesystem in one
7
+ * narrow way (see {@link canonicalize}): resolving symlinks so a path
8
+ * comparison isn't fooled by one, which is cheap (a handful of stat calls,
9
+ * not a plan load) and safe to call on paths that don't exist yet.
10
+ */
11
+ export type GuardPermissionDecision = "allow" | "ask" | "deny";
12
+ /** The tool-use fields the decision needs, already pulled out of whatever
13
+ * shape the harness's raw event uses (Claude Code's `tool_input.file_path`,
14
+ * Pi's `event.input.path`, etc.). */
15
+ export interface GuardEventInput {
16
+ toolName: string;
17
+ /** Absolute (or resolvable) working directory used to resolve relative paths. */
18
+ cwd: string;
19
+ /** Absolute planner root, e.g. `join(cwd, ".planner")`. */
20
+ plannerRoot: string;
21
+ /** Edit / Write target path. */
22
+ filePath?: string;
23
+ /** NotebookEdit target path. */
24
+ notebookPath?: string;
25
+ /** Bash command line, when toolName is "Bash". */
26
+ command?: string;
27
+ }
28
+ /** Planner state the caller already fetched (I/O happens once, upstream). */
29
+ export interface GuardStateInput {
30
+ hasPlannerDir: boolean;
31
+ totalTasks: number;
32
+ hasInProgressTask: boolean;
33
+ guardBypassed: boolean;
34
+ /** Human-readable "start this task" hint, or the generic fallback; empty allowed. */
35
+ startHint: string;
36
+ }
37
+ export interface GuardClassification {
38
+ /** Whether this tool call is one the guard evaluates at all. */
39
+ guarded: boolean;
40
+ /** Best-effort candidate paths the call would write to (may be empty when unknown). */
41
+ paths: string[];
42
+ }
43
+ export interface GuardDecision {
44
+ decision: GuardPermissionDecision;
45
+ reason?: string;
46
+ }
47
+ /**
48
+ * Whether a Bash command line contains a write-shaped operation: output
49
+ * redirection, `tee`, in-place `sed`/`perl`, a file-creating `cp`/`mv`,
50
+ * `rm`, or a `git` operation that rewrites tracked files. Deliberately
51
+ * over-inclusive: `git`, build/test runners, `ls`, `cat`, `grep`, `find`
52
+ * and friends must stay unmatched, but when in doubt this returns true — the
53
+ * cost of a false positive here is one extra prompt, not a blocked
54
+ * operation, so under-matching (missing a real edit) is the worse failure.
55
+ */
56
+ export declare function isWriteShapedBashCommand(command: string): boolean;
57
+ /**
58
+ * Best-effort extraction of the file path(s) a write-shaped Bash segment
59
+ * targets, so a command that only touches `.planner/` can still be
60
+ * recognized and allowed. This cannot see through a heredoc whose target is
61
+ * computed (a variable, command substitution, etc.) rather than written
62
+ * literally in the command line — that case is expected to fall through to
63
+ * the normal in-progress/bypass checks below, not to be silently allowed.
64
+ *
65
+ * A wider gap of the same kind: a command that writes through an
66
+ * interpreter (`python3 - <<PY` then `open(path, "w")`, `node -e`, a script
67
+ * invoked by name) is opaque here — nothing in the command line looks
68
+ * write-shaped. P105(F005)/T431 evaluated closing this and chose not to,
69
+ * for reasons that also rule out revisiting it the same way later:
70
+ * - Parsing the script body is unbounded (a path can be computed, read
71
+ * from argv, or built at runtime) and would trade an honest documented
72
+ * limit for false confidence.
73
+ * - A PostToolUse hook that notices tracked files changed while no task
74
+ * was in progress sounds like a fit (the guard's job is visibility, not
75
+ * prevention), but it collides with this project's own workflow: a
76
+ * task is left with its changes uncommitted for the orchestrator to
77
+ * review, so "no task in-progress, tracked files changed outside
78
+ * .planner/" is the routine state right after every task completes,
79
+ * not a signal of anything wrong. Telling that apart from a real
80
+ * interpreter write means diffing only the delta from one specific
81
+ * Bash call, which needs a session-scoped git-status baseline checked
82
+ * on every Bash invocation — read-only ones included, since which
83
+ * commands wrote can't be known in advance. That statefulness and
84
+ * per-call cost is worse than the gap it would close.
85
+ * The limit stands, documented rather than silently assumed away.
86
+ */
87
+ export declare function extractBashWriteTargets(command: string): string[];
88
+ /** Whether `candidate` resolves to a path inside `plannerRoot` (or is it exactly). */
89
+ export declare function isPathInsidePlannerRoot(candidate: string, plannerRoot: string, cwd: string): boolean;
90
+ /** True only when every candidate path is inside the planner root, and there
91
+ * is at least one candidate — an empty/unknown path list must never count as
92
+ * "inside", or an unresolvable target would be silently allowed. Exported so
93
+ * a hook harness can use the exact same check for its cheap early-allow path
94
+ * without duplicating the rule. */
95
+ export declare function isEntirelyInsidePlannerRoot(paths: string[], plannerRoot: string, cwd: string): boolean;
96
+ /**
97
+ * Which tool calls the guard covers, and what path(s) they would write to.
98
+ * Everything else (Read, Grep, WebFetch, MCP tools, read-only Bash, ...) is
99
+ * unguarded and returns `{ guarded: false }` without touching the planner
100
+ * store.
101
+ */
102
+ export declare function classifyGuardedTool(event: GuardEventInput): GuardClassification;
103
+ /**
104
+ * The full "no task in progress" guard decision, as a pure function: given a
105
+ * parsed tool-use event and planner state the caller already fetched, decide
106
+ * allow or ask (deny is part of the type for a future genuinely-deny case,
107
+ * but nothing here returns it today). No stdin/stdout and no plan-store
108
+ * calls — the hook harness (or a Pi equivalent) does the reading and
109
+ * printing around this; the only filesystem access is the cheap symlink
110
+ * canonicalization in {@link isPathInsidePlannerRoot}.
111
+ *
112
+ * Order matters and mirrors the cost of finding out: anything the guard
113
+ * doesn't cover, and anything that resolves entirely inside the planner
114
+ * root, is decided from the event alone (no planner state needed). Only a
115
+ * real candidate for blocking reaches the state-based checks.
116
+ */
117
+ export declare function decideGuardPreToolUse(event: GuardEventInput, state: GuardStateInput): GuardDecision;
118
+ //# 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":"AAGA;;;;;;;;;GASG;AAEH,MAAM,MAAM,uBAAuB,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAE/D;;qCAEqC;AACrC,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,WAAW,EAAE,MAAM,CAAC;IACpB,gCAAgC;IAChC,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,qFAAqF;IACrF,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,OAAO,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,uBAAuB,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAsCD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA2BjE;AAsBD,sFAAsF;AACtF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAKpG;AAED;;;;mCAImC;AACnC,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAEtG;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,eAAe,GAAG,mBAAmB,CAa/E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,eAAe,GAAG,aAAa,CAkBnG"}