@agent-plan/core 0.2.22 → 0.2.23

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-session, ordered context-read enforcement for agent lifecycle operations.
3
+ *
4
+ * An agent must read the exact task, then its parent phase, then its parent
5
+ * feature with full=true before it can start, resume, or switch to that task.
6
+ * Compact list/identity reads never count. The state is process-local and is
7
+ * cleared on pause, switch, and session startup by the harness adapters.
8
+ *
9
+ * Linked requirements remain a separate explicit-read gate. They are recorded
10
+ * only through the requirement list tool and never by an entity read.
11
+ */
12
+ let state = {
13
+ tasks: new Map(),
14
+ phases: new Map(),
15
+ features: new Map(),
16
+ requirements: new Set(),
17
+ nextSequence: 0,
18
+ };
19
+ function record(map, id) {
20
+ state.nextSequence += 1;
21
+ map.set(id, state.nextSequence);
22
+ }
23
+ /** Record a full feature read. */
24
+ export function markFeatureRead(featureId) {
25
+ record(state.features, featureId);
26
+ }
27
+ /** Record a full phase read. The parent feature is intentionally not implied. */
28
+ export function markPhaseRead(phaseId, _featureId) {
29
+ record(state.phases, phaseId);
30
+ }
31
+ /** Record a full task read. Its parent phase and feature are intentionally not implied. */
32
+ export function markTaskRead(taskId, _phaseId, _featureId) {
33
+ record(state.tasks, taskId);
34
+ }
35
+ /** Record that a requirement was explicitly read via the requirement list tool. */
36
+ export function markRequirementRead(requirementId) {
37
+ state.requirements.add(requirementId);
38
+ }
39
+ /**
40
+ * Verify the required task(full) → phase(full) → feature(full) order for one
41
+ * exact task lineage. Orphan phases do not require a feature read.
42
+ */
43
+ export function contextReadEligibility(taskId, phaseId, featureId) {
44
+ const taskSequence = state.tasks.get(taskId);
45
+ if (taskSequence === undefined) {
46
+ return { eligible: false, reason: "Read this exact task with full=true first." };
47
+ }
48
+ const phaseSequence = state.phases.get(phaseId);
49
+ if (phaseSequence === undefined || phaseSequence <= taskSequence) {
50
+ return { eligible: false, reason: "After reading the task, read its parent phase with full=true." };
51
+ }
52
+ if (!featureId)
53
+ return { eligible: true, reason: "" };
54
+ const featureSequence = state.features.get(featureId);
55
+ if (featureSequence === undefined || featureSequence <= phaseSequence) {
56
+ return { eligible: false, reason: "After reading the phase, read its parent feature with full=true." };
57
+ }
58
+ return { eligible: true, reason: "" };
59
+ }
60
+ /**
61
+ * Legacy parent-read check retained for callers that only need to know whether
62
+ * both parent entities have been read. Lifecycle gates must use
63
+ * contextReadEligibility so the task and ordering cannot be bypassed.
64
+ */
65
+ export function hasReadParents(featureId, phaseId) {
66
+ const phaseOk = state.phases.has(phaseId);
67
+ const featureOk = featureId ? state.features.has(featureId) : true;
68
+ return phaseOk && featureOk;
69
+ }
70
+ /** Whether every linked requirement has been explicitly read. */
71
+ export function hasReadRequirements(requirementIds) {
72
+ return requirementIds.every((id) => state.requirements.has(id));
73
+ }
74
+ /** Clear all read state so later lifecycle work requires fresh context. */
75
+ export function invalidateReads() {
76
+ state = {
77
+ tasks: new Map(),
78
+ phases: new Map(),
79
+ features: new Map(),
80
+ requirements: new Set(),
81
+ nextSequence: 0,
82
+ };
83
+ }
84
+ /** Compatibility advisory for non-lifecycle callers. */
85
+ export function parentReadAdvisory(featureId, phaseId) {
86
+ if (hasReadParents(featureId, phaseId))
87
+ return "";
88
+ return "\n\n⚠️ READ REQUIRED before proceeding: read the parent phase and feature with full=true.";
89
+ }
90
+ /** Advisory text for the separate linked-requirements gate. */
91
+ export function requirementReadAdvisory(requirementIds) {
92
+ if (requirementIds.length === 0)
93
+ return "";
94
+ const unread = requirementIds.filter((id) => !state.requirements.has(id));
95
+ if (unread.length === 0)
96
+ return "";
97
+ return "\n\n⚠️ REQUIREMENTS READ REQUIRED before proceeding: read the requirements linked to this phase and feature.";
98
+ }
99
+ /** Snapshot for diagnostics and tests. */
100
+ export function readTrackingSnapshot() {
101
+ return {
102
+ features: [...state.features.keys()],
103
+ phases: [...state.phases.keys()],
104
+ requirements: [...state.requirements],
105
+ };
106
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"recap.d.ts","sourceRoot":"","sources":["../src/recap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAGjD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,GAAE,YAAiB,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmOhH"}
1
+ {"version":3,"file":"recap.d.ts","sourceRoot":"","sources":["../src/recap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAGjD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,GAAE,YAAiB,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4OhH"}
package/dist/recap.js CHANGED
@@ -33,20 +33,20 @@ export async function buildRecap(st, web = {}, opts = {}) {
33
33
  const totalT = allTasks.length;
34
34
  const doneT = allTasks.filter(({ task }) => task.status === "done").length;
35
35
  const activeT = allTasks.filter(({ task }) => task.status === "in-progress").length;
36
- const pausedTasks = allTasks.filter(({ task }) => task.status === "paused");
37
- const pausedT = pausedTasks.length;
36
+ const checkpointedTasks = allTasks.filter(({ task }) => !["done", "canceled", "rejected"].includes(task.status) && task.pauseSnapshot);
37
+ const checkpointedT = checkpointedTasks.length;
38
38
  const pendingDeviation = [...plan.project.workDeviations]
39
39
  .filter((deviation) => deviation.state === "resume-required" || deviation.state === "resolved")
40
40
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
41
41
  .find((deviation) => allTasks.some(({ task }) => task.id === deviation.resumeTaskId
42
- && (task.status === "paused" || task.status === "planned" || task.status === "waiting")));
42
+ && (task.status === "planned" || task.status === "waiting" || task.status === "in-progress")));
43
43
  const pendingResume = pendingDeviation
44
44
  ? allTasks.find(({ task }) => task.id === pendingDeviation.resumeTaskId)
45
45
  : undefined;
46
- const standalonePaused = pausedTasks
46
+ const standaloneCheckpoints = checkpointedTasks
47
47
  .filter(({ task }) => task.id !== pendingResume?.task.id)
48
48
  .sort((left, right) => (right.task.pauseSnapshot?.pausedAt ?? "").localeCompare(left.task.pauseSnapshot?.pausedAt ?? ""));
49
- const latestStandalonePaused = standalonePaused[0];
49
+ const latestStandaloneCheckpoint = standaloneCheckpoints[0];
50
50
  // Plan is fully complete: there is work and all of it is done, nothing active.
51
51
  // (totalT > 0 guards the empty/unstarted case from looking "complete".)
52
52
  const planComplete = totalT > 0 && doneT === totalT && doneP === totalP && doneF === totalF;
@@ -63,28 +63,34 @@ export async function buildRecap(st, web = {}, opts = {}) {
63
63
  const featureAddCmd = cmd("/planner feature add", "planner-feature-add");
64
64
  const phaseAddCmd = cmd("/planner phase add", "planner-phase-add");
65
65
  const handoffShowCmd = cmd("/planner handoff show", "planner-handoff-show");
66
+ const featureShowCmd = cmd("/planner feature show", "planner-feature-show");
67
+ const phaseShowCmd = cmd("/planner phase show", "planner-phase-show");
68
+ const taskShowCmd = cmd("/planner task show", "planner-task-show");
66
69
  const lines = [];
67
70
  lines.push(italian ? "## Ripresa planner" : "## Planner recap");
68
71
  const name = plan.project.name || "(unnamed project)";
69
72
  lines.push(`${italian ? "Progetto" : "Project"}: ${name}${plan.project.goal ? " — " + plan.project.goal : ""}`);
70
73
  lines.push(italian
71
- ? `Avanzamento: feature ${doneF}/${totalF} completate (${activeF} attive) · fasi ${doneP}/${totalP} completate (${activeP} attive) · task ${doneT}/${totalT} completati (${activeT} attivi, ${pausedT} in pausa)`
72
- : `Progress: Features ${doneF}/${totalF} done (${activeF} active) · Phases ${doneP}/${totalP} done (${activeP} active) · Tasks ${doneT}/${totalT} done (${activeT} active, ${pausedT} paused)`);
74
+ ? `Avanzamento: feature ${doneF}/${totalF} completate (${activeF} attive) · fasi ${doneP}/${totalP} completate (${activeP} attive) · task ${doneT}/${totalT} completati (${activeT} attivi, ${checkpointedT} con checkpoint)`
75
+ : `Progress: Features ${doneF}/${totalF} done (${activeF} active) · Phases ${doneP}/${totalP} done (${activeP} active) · Tasks ${doneT}/${totalT} done (${activeT} active, ${checkpointedT} with checkpoints)`);
73
76
  if (focusTask && focusPhase) {
74
77
  const fr = focusFeature ? fref(focusFeature.number) : "?";
75
78
  const pr = formatPhaseRef(focusPhase.number, focusFeature?.number);
76
79
  const tr = tref(focusTask.task.number);
77
80
  lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${fr} — ${focusFeature?.name ?? "?"} / ${pr} — ${focusPhase.title} / ${tr} — ${focusTask.task.title} (in-progress)`);
81
+ lines.push("", italian
82
+ ? `Questo task è in-progress (lavoro iniziato in una sessione precedente). Prima di continuare, rileggi il contesto completo: ${taskShowCmd} ${tr} (full=true), ${phaseShowCmd} ${pr} (full=true), ${featureShowCmd} ${fr} (full=true).`
83
+ : `⚠️ This task is in-progress (work started in a previous session). Before continuing, re-read the full context in this order: ${taskShowCmd} ${tr} (full=true), ${phaseShowCmd} ${pr} (full=true), ${featureShowCmd} ${fr} (full=true).`);
78
84
  }
79
85
  else if (pendingResume) {
80
86
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
81
87
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
82
88
  lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${italian ? "ripresa obbligatoria" : "resume required"} — ${ref} — ${pendingResume.task.title} (${pendingResume.task.status})`);
83
89
  }
84
- else if (latestStandalonePaused) {
85
- const feature = feats.find((entry) => entry.id === latestStandalonePaused.phase.featureId);
86
- const ref = `${formatPhaseRef(latestStandalonePaused.phase.number, feature?.number)}/${tref(latestStandalonePaused.task.number)}`;
87
- lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${italian ? "task in pausa da riprendere" : "paused task to resume"} — ${ref} — ${latestStandalonePaused.task.title}`);
90
+ else if (latestStandaloneCheckpoint) {
91
+ const feature = feats.find((entry) => entry.id === latestStandaloneCheckpoint.phase.featureId);
92
+ const ref = `${formatPhaseRef(latestStandaloneCheckpoint.phase.number, feature?.number)}/${tref(latestStandaloneCheckpoint.task.number)}`;
93
+ lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${italian ? "checkpoint da valutare" : "checkpoint to evaluate"} — ${ref} — ${latestStandaloneCheckpoint.task.title}`);
88
94
  }
89
95
  else if (handoffs.length > 0) {
90
96
  const top = handoffs[0];
@@ -107,15 +113,15 @@ export async function buildRecap(st, web = {}, opts = {}) {
107
113
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
108
114
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
109
115
  lines.push(italian
110
- ? `Prossimo step: riprendi ${ref} con ${taskStartCmd}. Il checkpoint del task è prioritario rispetto a nuovo lavoro e handoff di fase.`
111
- : `Next step: resume ${ref} with ${taskStartCmd}. Its task checkpoint takes precedence over new work and phase handoffs.`);
116
+ ? `Avviso ripresa: valuta se riprendere ${ref} con ${taskStartCmd}. Il checkpoint del task va letto prima di decidere se tornare su questo lavoro o proseguire altrove.`
117
+ : `Resume advisory: evaluate whether to resume ${ref} with ${taskStartCmd}. Read its checkpoint before deciding whether to return to this work or continue elsewhere.`);
112
118
  }
113
- else if (latestStandalonePaused) {
114
- const feature = feats.find((entry) => entry.id === latestStandalonePaused.phase.featureId);
115
- const ref = `${formatPhaseRef(latestStandalonePaused.phase.number, feature?.number)}/${tref(latestStandalonePaused.task.number)}`;
119
+ else if (latestStandaloneCheckpoint) {
120
+ const feature = feats.find((entry) => entry.id === latestStandaloneCheckpoint.phase.featureId);
121
+ const ref = `${formatPhaseRef(latestStandaloneCheckpoint.phase.number, feature?.number)}/${tref(latestStandaloneCheckpoint.task.number)}`;
116
122
  lines.push(italian
117
- ? `Prossimo step: riprendi il checkpoint più recente, ${ref}, con ${taskStartCmd}, prima di scegliere nuovo lavoro.`
118
- : `Next step: resume the newest checkpoint, ${ref}, with ${taskStartCmd} before selecting new work.`);
123
+ ? `Avviso ripresa: valuta il checkpoint più recente, ${ref}, con ${taskStartCmd}, prima di scegliere se tornare su quel lavoro o avviarne altro.`
124
+ : `Resume advisory: evaluate the newest checkpoint, ${ref}, with ${taskStartCmd} before deciding whether to return to it or start other work.`);
119
125
  }
120
126
  else if (handoffs.length > 0) {
121
127
  lines.push(italian
@@ -137,11 +143,11 @@ export async function buildRecap(st, web = {}, opts = {}) {
137
143
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
138
144
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
139
145
  const snapshot = pendingResume.task.pauseSnapshot ?? pendingDeviation?.snapshot;
140
- lines.push("", italian ? "## Ripresa task obbligatoria" : "## Task resume required", `${ref} — ${pendingResume.task.title}`, snapshot ? `${italian ? "Perché è stato sospeso" : "Paused because"}: ${snapshot.reason}` : "", snapshot ? `${italian ? "Stato del lavoro" : "Work checkpoint"}: ${snapshot.whatWasBeingDone}` : "", snapshot ? `${italian ? "Riprendi da" : "Resume from"}: ${snapshot.resumeLocation}` : "", snapshot ? `${italian ? "Come riprendere" : "How to resume"}: ${snapshot.howToResume}` : "", `${italian ? "Azione" : "Action"}: ${taskStartCmd} ${ref}`);
146
+ lines.push("", italian ? "## Avviso ripresa task" : "## Task resume advisory", `${ref} — ${pendingResume.task.title}`, snapshot ? `${italian ? "Perché è stato salvato il checkpoint" : "Checkpoint reason"}: ${snapshot.reason}` : "", snapshot ? `${italian ? "Stato del lavoro" : "Work checkpoint"}: ${snapshot.whatWasBeingDone}` : "", snapshot ? `${italian ? "Riprendi da" : "Resume from"}: ${snapshot.resumeLocation}` : "", snapshot ? `${italian ? "Come riprendere" : "How to resume"}: ${snapshot.howToResume}` : "", italian ? `Azione suggerita: valuta ${taskStartCmd} ${ref}` : `Suggested action: evaluate ${taskStartCmd} ${ref}`);
141
147
  }
142
- if (standalonePaused.length > 0) {
143
- lines.push("", italian ? `## Task in pausa (${standalonePaused.length})` : `## Paused tasks (${standalonePaused.length})`);
144
- for (const entry of standalonePaused) {
148
+ if (standaloneCheckpoints.length > 0) {
149
+ lines.push("", italian ? `## Checkpoint salvati (${standaloneCheckpoints.length})` : `## Saved checkpoints (${standaloneCheckpoints.length})`);
150
+ for (const entry of standaloneCheckpoints) {
145
151
  const feature = feats.find((item) => item.id === entry.phase.featureId);
146
152
  const ref = `${formatPhaseRef(entry.phase.number, feature?.number)}/${tref(entry.task.number)}`;
147
153
  const snapshot = entry.task.pauseSnapshot;
@@ -152,7 +158,7 @@ export async function buildRecap(st, web = {}, opts = {}) {
152
158
  const top = handoffs[0];
153
159
  lines.push("", italian ? `## Handoff di fase pendenti (${handoffs.length})` : `## Pending phase handoffs (${handoffs.length})`);
154
160
  handoffs.forEach((h, i) => lines.push(`[${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`));
155
- if (pendingResume || latestStandalonePaused) {
161
+ if (pendingResume || latestStandaloneCheckpoint) {
156
162
  lines.push("", italian
157
163
  ? "→ Questi handoff restano disponibili come contesto, ma prima risolvi il task da riprendere indicato sopra."
158
164
  : "→ These handoffs remain available as context, but first resolve the task resume shown above.");
@@ -173,7 +179,7 @@ export async function buildRecap(st, web = {}, opts = {}) {
173
179
  ? `Piano completo — aggiungi una nuova feature (${featureAddCmd}) o fase (${phaseAddCmd}) per continuare.`
174
180
  : `Plan complete — add a new feature (${featureAddCmd}) or phase (${phaseAddCmd}) to continue.`);
175
181
  }
176
- else if (activeT === 0 && !pendingResume && standalonePaused.length === 0) {
182
+ else if (activeT === 0 && !pendingResume && standaloneCheckpoints.length === 0) {
177
183
  lines.push("", italian
178
184
  ? `Nessun handoff pendente e nessun task in-progress. Usa ${taskAddCmd} / ${taskStartCmd} per iniziare.`
179
185
  : `No phase handoff pending and no task in-progress. Use ${taskAddCmd} / ${taskStartCmd} to begin work.`);