@agent-plan/core 0.2.21 → 0.2.22-next.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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * T307 (P072/F005) — tracked, deduplicated parent read-enforcement.
3
+ *
4
+ * Analogous to the existing NO ACTIVE TASK advisory: an in-memory, per-session
5
+ * record of which feature/phase refs the agent has actually read (via the
6
+ * feature/phase/task get/show tools). Starting, resuming, or switching a task
7
+ * checks that the target's parent feature + phase are in the read set; a missing
8
+ * read yields an unavoidable (non-blocking) advisory instead of a silent skip.
9
+ *
10
+ * Dedupe: reading a phase (or any task inside it) records both its phase and
11
+ * feature, so 10 sibling tasks need a single feature + phase read. Switching to
12
+ * a different phase/feature naturally fails (that phase is not recorded). A
13
+ * pause invalidates the set, so resuming forces a fresh read.
14
+ *
15
+ * T319 (P072/F005) extends the set with requirements: starting/resuming/switching
16
+ * also requires the requirements linked to the phase (and, when present, the
17
+ * feature) to have been explicitly read. Requirements are NOT auto-recorded by a
18
+ * phase/feature/task read (per project decision: explicit separate read); they
19
+ * are recorded only via the requirement list tool (markRequirementRead). An empty
20
+ * linked-requirement list means nothing is required.
21
+ */
22
+ let state = { features: new Set(), phases: new Set(), requirements: new Set() };
23
+ /** Record that a feature was read. Does NOT imply any phase was read. */
24
+ export function markFeatureRead(featureId) {
25
+ state.features.add(featureId);
26
+ }
27
+ /** Record that a phase (and, when known, its feature) was read. */
28
+ export function markPhaseRead(phaseId, featureId) {
29
+ state.phases.add(phaseId);
30
+ if (featureId)
31
+ state.features.add(featureId);
32
+ }
33
+ /** Record that a task (and, by context, its parent phase + feature) was read. */
34
+ export function markTaskRead(_taskId, phaseId, featureId) {
35
+ state.phases.add(phaseId);
36
+ if (featureId)
37
+ state.features.add(featureId);
38
+ }
39
+ /** Record that a requirement was explicitly read (via the requirement list tool). */
40
+ export function markRequirementRead(requirementId) {
41
+ state.requirements.add(requirementId);
42
+ }
43
+ /**
44
+ * Whether the target task's parent feature + phase have both been read.
45
+ * `featureId` is optional: an orphan phase (no feature) only requires the phase.
46
+ */
47
+ export function hasReadParents(featureId, phaseId) {
48
+ const phaseOk = state.phases.has(phaseId);
49
+ const featureOk = featureId ? state.features.has(featureId) : true;
50
+ return phaseOk && featureOk;
51
+ }
52
+ /** Whether every linked requirement (phase + feature) has been explicitly read. */
53
+ export function hasReadRequirements(requirementIds) {
54
+ return requirementIds.every((id) => state.requirements.has(id));
55
+ }
56
+ /** Clear the read set (e.g. on pause, so resuming forces a fresh read). */
57
+ export function invalidateReads() {
58
+ state = { features: new Set(), phases: new Set(), requirements: new Set() };
59
+ }
60
+ /**
61
+ * Non-blocking advisory (mirrors the NO ACTIVE TASK pattern): empty when the
62
+ * target's parent feature + phase have both been read, otherwise a loud
63
+ * READ REQUIRED notice the agent cannot silently skip.
64
+ */
65
+ export function parentReadAdvisory(featureId, phaseId) {
66
+ if (hasReadParents(featureId, phaseId))
67
+ return "";
68
+ return "\n\n⚠️ READ REQUIRED before proceeding: read the parent feature and phase (full=true) for this task before starting or resuming it. One feature+phase read covers every task in that phase; a pause/resume invalidates the read and requires re-reading.";
69
+ }
70
+ /**
71
+ * Non-blocking advisory (mirrors parentReadAdvisory): empty when every linked
72
+ * requirement (phase + feature) has been explicitly read, otherwise a loud
73
+ * REQUIREMENTS READ REQUIRED notice. An empty list means the phase/feature has
74
+ * no linked requirements, so nothing is required.
75
+ */
76
+ export function requirementReadAdvisory(requirementIds) {
77
+ if (requirementIds.length === 0)
78
+ return "";
79
+ const unread = requirementIds.filter((id) => !state.requirements.has(id));
80
+ if (unread.length === 0)
81
+ return "";
82
+ return "\n\n⚠️ REQUIREMENTS READ REQUIRED before proceeding: read the requirements linked to this phase (and feature) before starting or resuming the task. Use the requirement list tool to read them (one read covers every task in that phase; a pause/resume invalidates the read).";
83
+ }
84
+ /** Snapshot for diagnostics/tests. */
85
+ export function readTrackingSnapshot() {
86
+ return { features: [...state.features], phases: [...state.phases], requirements: [...state.requirements] };
87
+ }
@@ -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,CAyPhH"}
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: ${featureShowCmd} ${fr}, ${phaseShowCmd} ${pr}, ${taskShowCmd} ${tr} (full).`
83
+ : `⚠️ This task is in-progress (work started in a previous session). Before continuing, re-read the full context: ${featureShowCmd} ${fr}, ${phaseShowCmd} ${pr}, ${taskShowCmd} ${tr} (full).`);
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];
@@ -98,6 +104,15 @@ export async function buildRecap(st, web = {}, opts = {}) {
98
104
  else {
99
105
  lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${italian ? "nessun task attivo — rivedi il piano e scegli il prossimo task concreto" : "no active task — review the plan and pick the next concrete task"}`);
100
106
  }
107
+ // Planner extension rules — the agent-behavior contract for every project
108
+ // using the extension. Static (no timestamps), loaded from .planner/rules.json
109
+ // or the canonical code set. Kept out of project.json so it never diverges
110
+ // across worktrees/branches.
111
+ const extensionRules = await st.extensionRules();
112
+ if (extensionRules.length > 0) {
113
+ lines.push("", italian ? "## Regole del planner (estensione)" : "## Planner rules (extension)");
114
+ extensionRules.forEach((rule, i) => lines.push(`${i + 1}. ${rule}`));
115
+ }
101
116
  // Next step: the phase handoff (phase.handoff) is the authoritative,
102
117
  // actively-managed resume context. When a handoff is pending, point to it
103
118
  // instead of surfacing potentially-stale resume.json nextSteps. When no
@@ -107,15 +122,15 @@ export async function buildRecap(st, web = {}, opts = {}) {
107
122
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
108
123
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
109
124
  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.`);
125
+ ? `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.`
126
+ : `Resume advisory: evaluate whether to resume ${ref} with ${taskStartCmd}. Read its checkpoint before deciding whether to return to this work or continue elsewhere.`);
112
127
  }
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)}`;
128
+ else if (latestStandaloneCheckpoint) {
129
+ const feature = feats.find((entry) => entry.id === latestStandaloneCheckpoint.phase.featureId);
130
+ const ref = `${formatPhaseRef(latestStandaloneCheckpoint.phase.number, feature?.number)}/${tref(latestStandaloneCheckpoint.task.number)}`;
116
131
  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.`);
132
+ ? `Avviso ripresa: valuta il checkpoint più recente, ${ref}, con ${taskStartCmd}, prima di scegliere se tornare su quel lavoro o avviarne altro.`
133
+ : `Resume advisory: evaluate the newest checkpoint, ${ref}, with ${taskStartCmd} before deciding whether to return to it or start other work.`);
119
134
  }
120
135
  else if (handoffs.length > 0) {
121
136
  lines.push(italian
@@ -137,11 +152,11 @@ export async function buildRecap(st, web = {}, opts = {}) {
137
152
  const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
138
153
  const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
139
154
  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}`);
155
+ 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
156
  }
142
- if (standalonePaused.length > 0) {
143
- lines.push("", italian ? `## Task in pausa (${standalonePaused.length})` : `## Paused tasks (${standalonePaused.length})`);
144
- for (const entry of standalonePaused) {
157
+ if (standaloneCheckpoints.length > 0) {
158
+ lines.push("", italian ? `## Checkpoint salvati (${standaloneCheckpoints.length})` : `## Saved checkpoints (${standaloneCheckpoints.length})`);
159
+ for (const entry of standaloneCheckpoints) {
145
160
  const feature = feats.find((item) => item.id === entry.phase.featureId);
146
161
  const ref = `${formatPhaseRef(entry.phase.number, feature?.number)}/${tref(entry.task.number)}`;
147
162
  const snapshot = entry.task.pauseSnapshot;
@@ -152,7 +167,7 @@ export async function buildRecap(st, web = {}, opts = {}) {
152
167
  const top = handoffs[0];
153
168
  lines.push("", italian ? `## Handoff di fase pendenti (${handoffs.length})` : `## Pending phase handoffs (${handoffs.length})`);
154
169
  handoffs.forEach((h, i) => lines.push(`[${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`));
155
- if (pendingResume || latestStandalonePaused) {
170
+ if (pendingResume || latestStandaloneCheckpoint) {
156
171
  lines.push("", italian
157
172
  ? "→ Questi handoff restano disponibili come contesto, ma prima risolvi il task da riprendere indicato sopra."
158
173
  : "→ These handoffs remain available as context, but first resolve the task resume shown above.");
@@ -173,7 +188,7 @@ export async function buildRecap(st, web = {}, opts = {}) {
173
188
  ? `Piano completo — aggiungi una nuova feature (${featureAddCmd}) o fase (${phaseAddCmd}) per continuare.`
174
189
  : `Plan complete — add a new feature (${featureAddCmd}) or phase (${phaseAddCmd}) to continue.`);
175
190
  }
176
- else if (activeT === 0 && !pendingResume && standalonePaused.length === 0) {
191
+ else if (activeT === 0 && !pendingResume && standaloneCheckpoints.length === 0) {
177
192
  lines.push("", italian
178
193
  ? `Nessun handoff pendente e nessun task in-progress. Usa ${taskAddCmd} / ${taskStartCmd} per iniziare.`
179
194
  : `No phase handoff pending and no task in-progress. Use ${taskAddCmd} / ${taskStartCmd} to begin work.`);