@agent-plan/core 0.2.20 → 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,CAkJhH"}
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,6 +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 checkpointedTasks = allTasks.filter(({ task }) => !["done", "canceled", "rejected"].includes(task.status) && task.pauseSnapshot);
37
+ const checkpointedT = checkpointedTasks.length;
38
+ const pendingDeviation = [...plan.project.workDeviations]
39
+ .filter((deviation) => deviation.state === "resume-required" || deviation.state === "resolved")
40
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
41
+ .find((deviation) => allTasks.some(({ task }) => task.id === deviation.resumeTaskId
42
+ && (task.status === "planned" || task.status === "waiting" || task.status === "in-progress")));
43
+ const pendingResume = pendingDeviation
44
+ ? allTasks.find(({ task }) => task.id === pendingDeviation.resumeTaskId)
45
+ : undefined;
46
+ const standaloneCheckpoints = checkpointedTasks
47
+ .filter(({ task }) => task.id !== pendingResume?.task.id)
48
+ .sort((left, right) => (right.task.pauseSnapshot?.pausedAt ?? "").localeCompare(left.task.pauseSnapshot?.pausedAt ?? ""));
49
+ const latestStandaloneCheckpoint = standaloneCheckpoints[0];
36
50
  // Plan is fully complete: there is work and all of it is done, nothing active.
37
51
  // (totalT > 0 guards the empty/unstarted case from looking "complete".)
38
52
  const planComplete = totalT > 0 && doneT === totalT && doneP === totalP && doneF === totalF;
@@ -49,18 +63,34 @@ export async function buildRecap(st, web = {}, opts = {}) {
49
63
  const featureAddCmd = cmd("/planner feature add", "planner-feature-add");
50
64
  const phaseAddCmd = cmd("/planner phase add", "planner-phase-add");
51
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");
52
69
  const lines = [];
53
70
  lines.push(italian ? "## Ripresa planner" : "## Planner recap");
54
71
  const name = plan.project.name || "(unnamed project)";
55
72
  lines.push(`${italian ? "Progetto" : "Project"}: ${name}${plan.project.goal ? " — " + plan.project.goal : ""}`);
56
73
  lines.push(italian
57
- ? `Avanzamento: feature ${doneF}/${totalF} completate (${activeF} attive) · fasi ${doneP}/${totalP} completate (${activeP} attive) · task ${doneT}/${totalT} completati (${activeT} attivi)`
58
- : `Progress: Features ${doneF}/${totalF} done (${activeF} active) · Phases ${doneP}/${totalP} done (${activeP} active) · Tasks ${doneT}/${totalT} done (${activeT} active)`);
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)`);
59
76
  if (focusTask && focusPhase) {
60
77
  const fr = focusFeature ? fref(focusFeature.number) : "?";
61
78
  const pr = formatPhaseRef(focusPhase.number, focusFeature?.number);
62
79
  const tr = tref(focusTask.task.number);
63
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).`);
84
+ }
85
+ else if (pendingResume) {
86
+ const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
87
+ const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
88
+ lines.push(`${italian ? "Focus corrente" : "Current focus"}: ${italian ? "ripresa obbligatoria" : "resume required"} — ${ref} — ${pendingResume.task.title} (${pendingResume.task.status})`);
89
+ }
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}`);
64
94
  }
65
95
  else if (handoffs.length > 0) {
66
96
  const top = handoffs[0];
@@ -74,12 +104,35 @@ export async function buildRecap(st, web = {}, opts = {}) {
74
104
  else {
75
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"}`);
76
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
+ }
77
116
  // Next step: the phase handoff (phase.handoff) is the authoritative,
78
117
  // actively-managed resume context. When a handoff is pending, point to it
79
118
  // instead of surfacing potentially-stale resume.json nextSteps. When no
80
119
  // handoff exists, fall back to resume.nextSteps but mark them as possibly
81
120
  // stale (free-text that refreshResume never touches, so they can drift).
82
- if (handoffs.length > 0) {
121
+ if (pendingResume) {
122
+ const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
123
+ const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
124
+ lines.push(italian
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.`);
127
+ }
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)}`;
131
+ lines.push(italian
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.`);
134
+ }
135
+ else if (handoffs.length > 0) {
83
136
  lines.push(italian
84
137
  ? `Prossimo step: leggi l'handoff di fase pendente sotto (fonte autorevole, gestita attivamente). I nextSteps legacy di resume.json sono soppressi perché possono essere stale.`
85
138
  : `Next step: read the pending phase handoff below (authoritative, actively maintained). Legacy resume.json nextSteps are suppressed because they may be stale.`);
@@ -95,11 +148,31 @@ export async function buildRecap(st, web = {}, opts = {}) {
95
148
  : `⚠️ nextSteps are free-text from resume.json — may be stale; verify against current state before acting.`);
96
149
  lines.push(staleNote);
97
150
  }
151
+ if (pendingResume) {
152
+ const feature = feats.find((entry) => entry.id === pendingResume.phase.featureId);
153
+ const ref = `${formatPhaseRef(pendingResume.phase.number, feature?.number)}/${tref(pendingResume.task.number)}`;
154
+ const snapshot = pendingResume.task.pauseSnapshot ?? pendingDeviation?.snapshot;
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}`);
156
+ }
157
+ if (standaloneCheckpoints.length > 0) {
158
+ lines.push("", italian ? `## Checkpoint salvati (${standaloneCheckpoints.length})` : `## Saved checkpoints (${standaloneCheckpoints.length})`);
159
+ for (const entry of standaloneCheckpoints) {
160
+ const feature = feats.find((item) => item.id === entry.phase.featureId);
161
+ const ref = `${formatPhaseRef(entry.phase.number, feature?.number)}/${tref(entry.task.number)}`;
162
+ const snapshot = entry.task.pauseSnapshot;
163
+ lines.push(`${ref} — ${entry.task.title}`, snapshot ? ` ${italian ? "Perché" : "Why"}: ${snapshot.reason}` : "", snapshot ? ` ${italian ? "Stato" : "Checkpoint"}: ${snapshot.whatWasBeingDone}` : "", snapshot ? ` ${italian ? "Riprendi da" : "Resume from"}: ${snapshot.resumeLocation}` : "", snapshot ? ` ${italian ? "Come" : "How"}: ${snapshot.howToResume}` : "");
164
+ }
165
+ }
98
166
  if (handoffs.length > 0) {
99
167
  const top = handoffs[0];
100
168
  lines.push("", italian ? `## Handoff di fase pendenti (${handoffs.length})` : `## Pending phase handoffs (${handoffs.length})`);
101
169
  handoffs.forEach((h, i) => lines.push(`[${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`));
102
- if (handoffs.length === 1) {
170
+ if (pendingResume || latestStandaloneCheckpoint) {
171
+ lines.push("", italian
172
+ ? "→ Questi handoff restano disponibili come contesto, ma prima risolvi il task da riprendere indicato sopra."
173
+ : "→ These handoffs remain available as context, but first resolve the task resume shown above.");
174
+ }
175
+ else if (handoffs.length === 1) {
103
176
  lines.push("", italian
104
177
  ? `→ Vuoi riprendere da ${top.compositeRef}? Leggi l'handoff con ${handoffShowCmd} ${top.compositeRef} e avvia il primo task pertinente. L'handoff resta attivo finché tutti i task della fase non sono done/canceled, viene sostituito da un nuovo handoff, oppure viene cancellato esplicitamente.`
105
178
  : `→ Do you want to resume from ${top.compositeRef}? Read the handoff with ${handoffShowCmd} ${top.compositeRef} and start the first relevant task. It stays active until every phase task is done/canceled, a new handoff replaces it, or it is explicitly cleared.`);
@@ -115,7 +188,7 @@ export async function buildRecap(st, web = {}, opts = {}) {
115
188
  ? `Piano completo — aggiungi una nuova feature (${featureAddCmd}) o fase (${phaseAddCmd}) per continuare.`
116
189
  : `Plan complete — add a new feature (${featureAddCmd}) or phase (${phaseAddCmd}) to continue.`);
117
190
  }
118
- else if (activeT === 0) {
191
+ else if (activeT === 0 && !pendingResume && standaloneCheckpoints.length === 0) {
119
192
  lines.push("", italian
120
193
  ? `Nessun handoff pendente e nessun task in-progress. Usa ${taskAddCmd} / ${taskStartCmd} per iniziare.`
121
194
  : `No phase handoff pending and no task in-progress. Use ${taskAddCmd} / ${taskStartCmd} to begin work.`);