@agent-plan/core 0.2.19-next.1 → 0.2.19-next.10

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.
@@ -1,4 +1,4 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
1
+ import { access, copyFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
4
4
  import { createFeatureId, createPhaseId, createRequirementId, createShortId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
@@ -176,6 +176,100 @@ export async function migrateToUuids(store) {
176
176
  await store.writeGenerated();
177
177
  });
178
178
  }
179
+ /**
180
+ * One-time idempotent migration to GLOBAL F/P/T numbering.
181
+ *
182
+ * Legacy plans assign Phase.number per-feature and Task.number per-phase, so
183
+ * every feature has a P001 and every phase has a T001 (ambiguous in chat/handoffs).
184
+ * This renumbers ALL features/phases/tasks by `createdAt` asc (stable tiebreak by
185
+ * id) into a single project-wide 1..N sequence and sets the monotonic project
186
+ * counters (nextFeatureNumber/nextPhaseNumber/nextTaskNumber).
187
+ *
188
+ * Idempotent: if no duplicate phase/task/feature numbers exist across the
189
+ * project, the plan is already global → no renumber writes happen (only the
190
+ * counters are ensured, in case project.json predates them). MUST run before
191
+ * ensureStructureOrdering (which no longer renumbers — numbers are stable).
192
+ */
193
+ export async function migrateToGlobalSequence(store) {
194
+ return store.runBatchForMigration(async () => {
195
+ const ws = await store.loadAll();
196
+ const phases = ws.phases;
197
+ const features = ws.features.features;
198
+ const project = ws.project;
199
+ const allTasks = [];
200
+ for (const phase of phases)
201
+ for (const task of phase.tasks)
202
+ allTasks.push({ phase, task });
203
+ const hasDupes = (nums) => new Set(nums).size !== nums.length;
204
+ const phaseDupes = hasDupes(phases.map((p) => p.number));
205
+ const taskDupes = hasDupes(allTasks.map((x) => x.task.number));
206
+ const featureDupes = hasDupes(features.map((f) => f.number));
207
+ const maxP = phases.reduce((m, p) => Math.max(m, p.number), 0);
208
+ const maxT = allTasks.reduce((m, x) => Math.max(m, x.task.number), 0);
209
+ const maxF = features.reduce((m, f) => Math.max(m, f.number), 0);
210
+ if (!phaseDupes && !taskDupes && !featureDupes) {
211
+ // Already global. Ensure counters are set (project.json may predate them).
212
+ let changed = false;
213
+ if (project.nextPhaseNumber <= maxP) {
214
+ project.nextPhaseNumber = maxP + 1;
215
+ changed = true;
216
+ }
217
+ if (project.nextTaskNumber <= maxT) {
218
+ project.nextTaskNumber = maxT + 1;
219
+ changed = true;
220
+ }
221
+ if (project.nextFeatureNumber <= maxF) {
222
+ project.nextFeatureNumber = maxF + 1;
223
+ changed = true;
224
+ }
225
+ if (changed)
226
+ await store.saveProject(project);
227
+ return { migrated: false, phases: phases.length, tasks: allTasks.length, features: features.length };
228
+ }
229
+ const renumber = (arr) => arr
230
+ .slice()
231
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
232
+ .map((x, i) => ({ ...x, number: i + 1 }));
233
+ const newFeatures = renumber(features);
234
+ const newPhases = renumber(phases);
235
+ const numberedTasks = renumber(allTasks.map((x) => x.task));
236
+ // Reassemble renumbered tasks into their phases, keyed by the task's OWN
237
+ // phaseId (source of truth). NOTE: do NOT pair `numberedTasks[i]` with
238
+ // `allTasks[i]!.phase.id` — the two arrays are in DIFFERENT orders
239
+ // (numberedTasks is sorted by createdAt, allTasks is in phase-iteration
240
+ // order), so a positional index would file each task under the wrong phase.
241
+ const phaseIdByTaskId = new Map(allTasks.map((x) => [x.task.id, x.phase.id]));
242
+ const newPhaseIds = new Set(newPhases.map((p) => p.id));
243
+ const tasksByPhase = new Map();
244
+ for (const t of numberedTasks) {
245
+ // Prefer the task's own phaseId when it points to a real phase; otherwise
246
+ // fall back to the phase the task was loaded from (handles legacy tasks
247
+ // with empty/stale phaseId without losing them).
248
+ const pid = (t.phaseId && newPhaseIds.has(t.phaseId)) ? t.phaseId : (phaseIdByTaskId.get(t.id) ?? "");
249
+ const bucket = tasksByPhase.get(pid) ?? [];
250
+ bucket.push(t);
251
+ tasksByPhase.set(pid, bucket);
252
+ }
253
+ const finalPhases = newPhases.map((p) => {
254
+ const tasks = tasksByPhase.get(p.id) ?? [];
255
+ const order = new Map(tasks.map((t) => [t.id, t]));
256
+ const ordered = p.taskIds.map((id) => order.get(id)).filter((t) => Boolean(t));
257
+ for (const t of tasks.sort((a, b) => a.number - b.number))
258
+ if (!ordered.includes(t))
259
+ ordered.push(t);
260
+ return { ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) };
261
+ });
262
+ await store.saveFeatures({ features: newFeatures });
263
+ for (const p of finalPhases)
264
+ await store.savePhase(p);
265
+ project.nextFeatureNumber = newFeatures.length + 1;
266
+ project.nextPhaseNumber = newPhases.length + 1;
267
+ project.nextTaskNumber = numberedTasks.length + 1;
268
+ await store.saveProject(project);
269
+ await store.writeGenerated();
270
+ return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
271
+ });
272
+ }
179
273
  async function readJson(path, schema) {
180
274
  try {
181
275
  const raw = await readFile(path, "utf-8");
@@ -240,24 +334,13 @@ export class PlanStore {
240
334
  // save. Kept so existing save* call sites compile unchanged.
241
335
  }
242
336
  normalizeTasks(tasks) {
243
- let changed = false;
244
- const normalized = tasks.map((task, index) => {
245
- const nextNumber = index + 1;
246
- if (task.number !== nextNumber)
247
- changed = true;
248
- return { ...task, number: nextNumber };
249
- });
250
- return { tasks: normalized, changed };
337
+ // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
338
+ // Do NOT renumber here renumbering would break references after deletions.
339
+ return { tasks, changed: false };
251
340
  }
252
341
  normalizeFeaturesDocument(doc) {
253
- let changed = false;
254
- const normalized = doc.features.map((feature, index) => {
255
- const nextNumber = index + 1;
256
- if (feature.number !== nextNumber)
257
- changed = true;
258
- return { ...feature, number: nextNumber };
259
- });
260
- return { doc: { features: normalized }, changed };
342
+ // Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
343
+ return { doc, changed: false };
261
344
  }
262
345
  normalizePhaseDocument(phase) {
263
346
  const { tasks, changed } = this.normalizeTasks(phase.tasks);
@@ -287,10 +370,7 @@ export class PlanStore {
287
370
  orphanPhases.push(phase);
288
371
  }
289
372
  }
290
- const normalizedFeatures = featuresDoc.features.map((feature, featureIndex) => {
291
- const nextFeatureNumber = featureIndex + 1;
292
- if (feature.number !== nextFeatureNumber)
293
- changed = true;
373
+ const normalizedFeatures = featuresDoc.features.map((feature) => {
294
374
  const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
295
375
  const linkedIds = new Set(linked.map((phase) => phase.id));
296
376
  const inferred = (phasesByFeature.get(feature.id) ?? []).filter((phase) => !linkedIds.has(phase.id));
@@ -299,12 +379,7 @@ export class PlanStore {
299
379
  if (normalizedPhaseIds.length !== feature.phaseIds.length || normalizedPhaseIds.some((id, index) => id !== feature.phaseIds[index])) {
300
380
  changed = true;
301
381
  }
302
- orderedPhases.forEach((phase, index) => {
303
- const nextPhaseNumber = index + 1;
304
- if (phase.number !== nextPhaseNumber) {
305
- phase.number = nextPhaseNumber;
306
- changed = true;
307
- }
382
+ orderedPhases.forEach((phase) => {
308
383
  const normalizedPhase = this.normalizePhaseDocument(phase);
309
384
  if (normalizedPhase.changed) {
310
385
  phase.tasks = normalizedPhase.phase.tasks;
@@ -314,16 +389,10 @@ export class PlanStore {
314
389
  });
315
390
  return {
316
391
  ...feature,
317
- number: nextFeatureNumber,
318
392
  phaseIds: normalizedPhaseIds,
319
393
  };
320
394
  });
321
- orphanPhases.forEach((phase, index) => {
322
- const nextPhaseNumber = index + 1;
323
- if (phase.number !== nextPhaseNumber) {
324
- phase.number = nextPhaseNumber;
325
- changed = true;
326
- }
395
+ orphanPhases.forEach((phase) => {
327
396
  const normalizedPhase = this.normalizePhaseDocument(phase);
328
397
  if (normalizedPhase.changed) {
329
398
  phase.tasks = normalizedPhase.phase.tasks;
@@ -338,7 +407,7 @@ export class PlanStore {
338
407
  };
339
408
  }
340
409
  async ensureStructureOrdering() {
341
- return this.runAsBatch(async () => {
410
+ const result = await this.runAsBatch(async () => {
342
411
  const featuresDoc = await this.loadFeatures();
343
412
  const phases = await this.loadAllPhases();
344
413
  const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
@@ -350,6 +419,52 @@ export class PlanStore {
350
419
  }
351
420
  return { changed: true };
352
421
  });
422
+ // One-time import of a legacy file-based HANDOFF.md (pre-F004) into the
423
+ // entity-scoped phase.handoff. Idempotent — renames the file to .bak.
424
+ await this.importLegacyHandoffFile().catch(() => { });
425
+ return result;
426
+ }
427
+ /** Rebuild each phase's `tasks` + `taskIds` from the task's OWN `phaseId`
428
+ * (source of truth). Heals plans where tasks got filed into the wrong phase
429
+ * file (e.g. the migrateToGlobalSequence index-mismatch bug, @agent-plan/core
430
+ * <0.2.19-next.7). Deterministic, lossless, idempotent: groups every task by
431
+ * its phaseId, preserves each phase's existing taskIds order, appends orphan
432
+ * tasks (whose phaseId dangles or is empty) by number. Writes a phase file
433
+ * only when its task set actually changed. */
434
+ async rebuildContainment() {
435
+ return this.runAsBatch(async () => {
436
+ const phases = await this.loadAllPhases();
437
+ const phaseById = new Map(phases.map((p) => [p.id, p]));
438
+ const allTasks = [];
439
+ for (const p of phases)
440
+ for (const t of p.tasks)
441
+ allTasks.push({ task: t, fromPhaseId: p.id });
442
+ const grouped = new Map();
443
+ let orphan = 0;
444
+ for (const { task, fromPhaseId } of allTasks) {
445
+ const pid = (task.phaseId && phaseById.has(task.phaseId)) ? task.phaseId : fromPhaseId;
446
+ if (!phaseById.has(pid))
447
+ orphan++;
448
+ const bucket = grouped.get(pid) ?? [];
449
+ bucket.push(task);
450
+ grouped.set(pid, bucket);
451
+ }
452
+ let changed = 0;
453
+ for (const p of phases) {
454
+ const tasks = grouped.get(p.id) ?? [];
455
+ const byId = new Map(tasks.map((t) => [t.id, t]));
456
+ const ordered = p.taskIds.map((id) => byId.get(id)).filter((t) => Boolean(t));
457
+ for (const t of tasks.slice().sort((a, b) => a.number - b.number))
458
+ if (!ordered.some((o) => o.id === t.id))
459
+ ordered.push(t);
460
+ const same = ordered.length === p.tasks.length && ordered.every((t, i) => t.id === p.tasks[i]?.id);
461
+ if (same)
462
+ continue;
463
+ await this.savePhase({ ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) });
464
+ changed++;
465
+ }
466
+ return { changed, tasks: allTasks.length, orphan };
467
+ });
353
468
  }
354
469
  // ── Path helpers ─────────────────────────────────────────────────────
355
470
  manifestPath() {
@@ -411,9 +526,6 @@ export class PlanStore {
411
526
  activityPath() {
412
527
  return join(this.root, "activity.json");
413
528
  }
414
- handoffPath() {
415
- return join(this.root, "HANDOFF.md");
416
- }
417
529
  // ── Init ─────────────────────────────────────────────────────────────
418
530
  async init(projectName) {
419
531
  if (await this.exists()) {
@@ -452,6 +564,9 @@ export class PlanStore {
452
564
  beforeTaskStart: [],
453
565
  afterPhaseComplete: [],
454
566
  },
567
+ nextFeatureNumber: 1,
568
+ nextPhaseNumber: 1,
569
+ nextTaskNumber: 1,
455
570
  });
456
571
  await this.saveRequirements({ requirements: [] });
457
572
  await this.saveFeatures({ features: [] });
@@ -460,6 +575,7 @@ export class PlanStore {
460
575
  currentPhaseId: "",
461
576
  inProgressTaskIds: [],
462
577
  nextSteps: ["Run /planner project discuss to bootstrap discovery"],
578
+ nextStepsUpdatedAt: nowISO(),
463
579
  blockers: [],
464
580
  notes: "Project initialized. Awaiting discovery.",
465
581
  lastSessionSummary: "",
@@ -482,12 +598,20 @@ export class PlanStore {
482
598
  "- `schema/plan.schema.json` — JSON Schema for tooling",
483
599
  ].join("\n");
484
600
  await writeFile(join(this.root, "README.md"), readme, "utf-8");
485
- // Write a .gitignore inside .planner/ so transient backup/tmp files are
601
+ // Write a .gitignore inside .planner/ so transient/derived files are
486
602
  // not tracked by the host project's git. Git respects nested .gitignore.
603
+ // - *.bak/*.tmp.*: crash backups from atomic writes
604
+ // - resume.json: per-session resume focus + the machine-local guard-bypass
605
+ // timestamp (guardBypassUntil must NOT leak into git/other clones)
606
+ // - generated/: auto-regenerated markdown views (derived from JSON; churn)
487
607
  await writeFile(join(this.root, ".gitignore"), [
488
- "# Agent Plan transient files — do not track",
608
+ "# Agent Plan transient/derived files — do not track",
489
609
  "*.bak",
490
610
  "*.tmp.*",
611
+ "resume.json",
612
+ "resume.*.json",
613
+ "generated/",
614
+ "handoff-archive/",
491
615
  "",
492
616
  ].join("\n"), "utf-8");
493
617
  }
@@ -507,6 +631,24 @@ export class PlanStore {
507
631
  async loadProject() {
508
632
  return readJson(this.projectPath(), ProjectSchema);
509
633
  }
634
+ /**
635
+ * Allocate the next global sequence number for a feature/phase/task.
636
+ * Reads the monotonic counter from project.json, increments it, persists,
637
+ * and returns the allocated number. MUST be called within withFeatureLock
638
+ * (adapters create entities inside a lock) so the counter is race-free.
639
+ * The counter never reuses a number — deletions leave gaps (by design:
640
+ * stable references survive deletion).
641
+ */
642
+ async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber"); }
643
+ async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber"); }
644
+ async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber"); }
645
+ async allocSeqNumber(key) {
646
+ const project = await this.loadProject();
647
+ const n = project[key];
648
+ project[key] = n + 1;
649
+ await this.saveProject(project);
650
+ return n;
651
+ }
510
652
  async loadPhase(phaseId) {
511
653
  const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
512
654
  const normalized = this.normalizePhaseDocument(raw).phase;
@@ -582,7 +724,18 @@ export class PlanStore {
582
724
  }
583
725
  }
584
726
  async saveResume(resume) {
585
- const parsed = ResumeFocusSchema.parse(resume);
727
+ // Track when `nextSteps` actually change (free-text can go stale; the recap
728
+ // surfaces nextStepsUpdatedAt so staleness is visible). Preserved when
729
+ // refreshResume keeps existing nextSteps; bumped only on a real change.
730
+ const existing = await this.loadResume().catch(() => null);
731
+ const nextStepsChanged = JSON.stringify(existing?.nextSteps ?? []) !== JSON.stringify(resume.nextSteps ?? []);
732
+ const withTs = {
733
+ ...resume,
734
+ nextStepsUpdatedAt: nextStepsChanged
735
+ ? nowISO()
736
+ : (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
737
+ };
738
+ const parsed = ResumeFocusSchema.parse(withTs);
586
739
  await atomicWriteJson(this.resumePath(), parsed);
587
740
  await this.touchManifest();
588
741
  }
@@ -598,6 +751,7 @@ export class PlanStore {
598
751
  currentPhaseId: "",
599
752
  inProgressTaskIds: [],
600
753
  nextSteps: [],
754
+ nextStepsUpdatedAt: "",
601
755
  blockers: [],
602
756
  notes: "",
603
757
  lastSessionSummary: "",
@@ -636,44 +790,6 @@ export class PlanStore {
636
790
  return { entries: [] };
637
791
  }
638
792
  }
639
- async handoffExists() {
640
- try {
641
- await access(this.handoffPath());
642
- return true;
643
- }
644
- catch {
645
- return false;
646
- }
647
- }
648
- async loadHandoff() {
649
- try {
650
- const [content, info] = await Promise.all([
651
- readFile(this.handoffPath(), "utf-8"),
652
- stat(this.handoffPath()),
653
- ]);
654
- const createdAt = content.match(/^Created at:\s*(.+)$/m)?.[1]?.trim() ?? info.birthtime.toISOString();
655
- const updatedAt = content.match(/^Updated at:\s*(.+)$/m)?.[1]?.trim() ?? info.mtime.toISOString();
656
- return {
657
- content,
658
- createdAt,
659
- updatedAt,
660
- };
661
- }
662
- catch {
663
- return null;
664
- }
665
- }
666
- async saveHandoff(content) {
667
- await atomicWriteText(this.handoffPath(), content);
668
- await this.touchManifest();
669
- }
670
- async deleteHandoff() {
671
- try {
672
- await unlink(this.handoffPath());
673
- }
674
- catch { }
675
- await this.touchManifest();
676
- }
677
793
  async appendActivity(type, ref, summary) {
678
794
  const log = await this.loadActivityLog();
679
795
  const id = `act-${log.entries.length + 1}-${type}`;
@@ -698,6 +814,7 @@ export class PlanStore {
698
814
  currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
699
815
  inProgressTaskIds: inProgressTasks.map((t) => t.id),
700
816
  nextSteps: existing?.nextSteps ?? [],
817
+ nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
701
818
  blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
702
819
  notes: notes ?? existing?.notes ?? "",
703
820
  lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
@@ -931,13 +1048,9 @@ export class PlanStore {
931
1048
  let shortIdsAssigned = 0;
932
1049
  let prioritiesAssigned = 0;
933
1050
  let featuresDirty = false;
934
- const assignPriority = (current, index) => {
935
- if (current === 0) {
936
- prioritiesAssigned += 1;
937
- return index + 1;
938
- }
939
- return current;
940
- };
1051
+ // Priority is left to reorder (midpoint-insert); ensureShortIds only
1052
+ // backfills shortIds. New items keep priority 0 until first drag reindex.
1053
+ const assignPriority = (current, _index) => current;
941
1054
  // Features: shortId + priority (project scope)
942
1055
  const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
943
1056
  sortedFeatures.forEach((f, index) => {
@@ -1017,9 +1130,13 @@ export class PlanStore {
1017
1130
  return this.runAsBatch(async () => {
1018
1131
  const migrated = await this.migratePhaseIds();
1019
1132
  const backfill = await this.ensureShortIdsAndPriority();
1133
+ // Rebuild phase containment from each task's own phaseId. Heals plans
1134
+ // corrupted by the migrateToGlobalSequence index-mismatch bug (core
1135
+ // <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
1136
+ const containment = await this.rebuildContainment();
1020
1137
  const integrity = await this.validateIntegrity();
1021
1138
  await this.writeGenerated();
1022
- return { migrated, backfill, integrity };
1139
+ return { migrated, backfill, containment, integrity };
1023
1140
  });
1024
1141
  }
1025
1142
  /** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
@@ -1121,7 +1238,7 @@ export class PlanStore {
1121
1238
  const phase = await this.loadPhase(phaseId);
1122
1239
  let cleared = null;
1123
1240
  if (phase.status === "done" && phase.handoff !== "") {
1124
- await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffUpdatedAt: nowISO() }));
1241
+ await this.clearPhaseHandoff(phaseId, "phase-done");
1125
1242
  const features = await this.loadFeatures();
1126
1243
  const feature = features.features.find((f) => f.id === phase.featureId);
1127
1244
  cleared = formatPhaseRef(phase.number, feature?.number);
@@ -1239,10 +1356,76 @@ export class PlanStore {
1239
1356
  const now = new Date().toISOString();
1240
1357
  await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
1241
1358
  }
1242
- /** Clear the handoff text for a phase (handoff=""). handoffUpdatedAt is left
1243
- * unchanged as an audit trail (when a handoff last existed). */
1244
- async clearPhaseHandoff(phaseId) {
1245
- await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
1359
+ /** Directory where cleared handoff content is archived as .md files
1360
+ * (gitignored). Keeps the phase JSON lean while making past handoffs
1361
+ * recoverable + human-readable. */
1362
+ handoffArchiveDir() {
1363
+ return join(this.root, "handoff-archive");
1364
+ }
1365
+ /** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
1366
+ * Does NOT clear the handoff — content is kept until a task starts or the
1367
+ * phase completes, so a restart between read and resume does not lose it. */
1368
+ async markHandoffRead(phaseId) {
1369
+ await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
1370
+ }
1371
+ /** One-time import of a legacy .planner/HANDOFF.md file (file-based handoff
1372
+ * from before F004) into the entity-scoped phase.handoff. Idempotent: if the
1373
+ * file is absent or empty, no-op. If it exists + non-empty + the target phase
1374
+ * has no handoff, writes the content onto the current in-progress phase (or
1375
+ * the first phase if none in-progress) with an "imported" handoffHistory entry,
1376
+ * then renames the file to HANDOFF.md.bak so it won't re-import. If the target
1377
+ * already has a handoff, the entity-scoped one wins and the file is just .bak'd. */
1378
+ async importLegacyHandoffFile() {
1379
+ const filePath = join(this.root, "HANDOFF.md");
1380
+ const content = await readFile(filePath, "utf-8").catch(() => null);
1381
+ if (content === null)
1382
+ return { imported: false };
1383
+ if (content.trim() === "") {
1384
+ await rename(filePath, filePath + ".bak").catch(() => { });
1385
+ return { imported: false };
1386
+ }
1387
+ const phases = await this.loadAllPhases();
1388
+ const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
1389
+ if (!target)
1390
+ return { imported: false }; // no phases yet — leave file for a later run
1391
+ if ((target.handoff ?? "") === "") {
1392
+ await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
1393
+ await this.updatePhase(target.id, (p) => ({
1394
+ ...p,
1395
+ handoffHistory: [{ file: "(legacy HANDOFF.md)", clearedAt: nowISO(), reason: "imported" }, ...(p.handoffHistory ?? [])].slice(0, 5),
1396
+ }));
1397
+ }
1398
+ await rename(filePath, filePath + ".bak").catch(() => { });
1399
+ const features = await this.loadFeatures();
1400
+ const feat = features.features.find((f) => f.id === target.featureId);
1401
+ return { imported: true, phaseRef: formatPhaseRef(target.number, feat?.number) };
1402
+ }
1403
+ /** Clear the handoff for a phase, archiving its content first. The handoff
1404
+ * markdown is written to .planner/handoff-archive/<phaseId>-<ISO>.md and a
1405
+ * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
1406
+ * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
1407
+ * left unchanged as an audit trail. If the handoff is empty, this is a no-op.
1408
+ * reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
1409
+ async clearPhaseHandoff(phaseId, reason = "manual") {
1410
+ const phase = await this.loadPhase(phaseId).catch(() => null);
1411
+ if (!phase || phase.handoff === "")
1412
+ return; // nothing to archive
1413
+ const clearedAt = nowISO();
1414
+ const safeTs = clearedAt.replace(/[:.]/g, "-");
1415
+ const archiveDir = this.handoffArchiveDir();
1416
+ await mkdir(archiveDir, { recursive: true }).catch(() => { });
1417
+ const fileName = `${phaseId}-${safeTs}.md`;
1418
+ const filePath = join(archiveDir, fileName);
1419
+ await atomicWriteText(filePath, phase.handoff);
1420
+ const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
1421
+ // Cap history at 5: prepend new entry, drop oldest (and delete its file).
1422
+ const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
1423
+ const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
1424
+ for (const d of dropped) {
1425
+ if (d?.file)
1426
+ await unlink(join(this.root, d.file)).catch(() => { });
1427
+ }
1428
+ await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
1246
1429
  }
1247
1430
  /** List all phases that have a non-empty handoff, newest first, with a
1248
1431
  * human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
@@ -1259,9 +1442,11 @@ export class PlanStore {
1259
1442
  const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
1260
1443
  out.push({
1261
1444
  phaseId: p.id,
1445
+ featureId: p.featureId,
1262
1446
  compositeRef: formatPhaseRef(p.number, fnum),
1263
1447
  updatedAt: p.handoffUpdatedAt || p.updatedAt,
1264
1448
  firstLine: handoffFirstLine(p.handoff),
1449
+ content: p.handoff,
1265
1450
  });
1266
1451
  }
1267
1452
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -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,CA+GhH"}
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,CA8HhH"}
package/dist/recap.js CHANGED
@@ -49,7 +49,6 @@ export async function buildRecap(st, web = {}, opts = {}) {
49
49
  const featureAddCmd = cmd("/planner feature add", "planner-feature-add");
50
50
  const phaseAddCmd = cmd("/planner phase add", "planner-phase-add");
51
51
  const handoffShowCmd = cmd("/planner handoff show", "planner-handoff-show");
52
- const handoffClearCmd = cmd("/planner handoff clear", "planner-handoff-clear");
53
52
  const lines = [];
54
53
  lines.push(italian ? "## Ripresa planner" : "## Planner recap");
55
54
  const name = plan.project.name || "(unnamed project)";
@@ -71,18 +70,33 @@ export async function buildRecap(st, web = {}, opts = {}) {
71
70
  else {
72
71
  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"}`);
73
72
  }
74
- // Next step: only surface resume.nextSteps when the plan is NOT complete —
75
- // otherwise stale init-time steps (e.g. "bootstrap discovery") leak through
76
- // and contradict an all-done plan.
77
- if (!planComplete && resume?.nextSteps?.length) {
73
+ // Next step: the phase handoff (phase.handoff) is the authoritative,
74
+ // actively-managed resume context. When a handoff is pending, point to it
75
+ // instead of surfacing potentially-stale resume.json nextSteps. When no
76
+ // handoff exists, fall back to resume.nextSteps but mark them as possibly
77
+ // stale (free-text that refreshResume never touches, so they can drift).
78
+ if (handoffs.length > 0) {
79
+ lines.push(italian
80
+ ? `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.`
81
+ : `Next step: read the pending phase handoff below (authoritative, actively maintained). Legacy resume.json nextSteps are suppressed because they may be stale.`);
82
+ }
83
+ else if (!planComplete && resume?.nextSteps?.length) {
78
84
  lines.push(`${italian ? "Prossimo step" : "Next step"}: ${resume.nextSteps[0]}`);
85
+ const staleNote = resume.nextStepsUpdatedAt
86
+ ? (italian
87
+ ? `⚠️ nextSteps free-text da resume.json, ultimo aggiornamento ${resume.nextStepsUpdatedAt} — può essere stale; verifica contro lo stato attuale prima di agire.`
88
+ : `⚠️ nextSteps are free-text from resume.json, last updated ${resume.nextStepsUpdatedAt} — may be stale; verify against current state before acting.`)
89
+ : (italian
90
+ ? `⚠️ nextSteps free-text da resume.json — può essere stale; verifica contro lo stato attuale prima di agire.`
91
+ : `⚠️ nextSteps are free-text from resume.json — may be stale; verify against current state before acting.`);
92
+ lines.push(staleNote);
79
93
  }
80
94
  if (handoffs.length > 0) {
81
95
  lines.push("", italian ? `## Handoff di fase pendenti (${handoffs.length})` : `## Pending phase handoffs (${handoffs.length})`);
82
96
  handoffs.forEach((h, i) => lines.push(`[${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`));
83
97
  lines.push("", italian
84
- ? `→ Leggi quello pertinente con ${handoffShowCmd} <ref> (valida contro lo stato attuale), poi ${handoffClearCmd} <ref> una volta consumato.`
85
- : `→ Read the relevant one with ${handoffShowCmd} <ref> (validate against current state), then call ${handoffClearCmd} <ref> once consumed (delete-on-resume).`);
98
+ ? `→ Leggi quello pertinente con ${handoffShowCmd} <ref> (valida contro lo stato attuale). L'handoff è MANTENUTO finché un task della fase non parte (auto-archiviato) o la fase non si conclude — non serve cancellarlo a mano.`
99
+ : `→ Read the relevant one with ${handoffShowCmd} <ref> (validate against current state). It is KEPT until a task in that phase starts (then auto-archived) or the phase completes — no need to clear it manually.`);
86
100
  }
87
101
  else if (planComplete) {
88
102
  lines.push("", italian
package/dist/refs.d.ts CHANGED
@@ -21,4 +21,27 @@ import type { Phase, Feature } from "./schema.js";
21
21
  * @param ref the human-facing ref (P00x / P00x(F00x) / UUID / title)
22
22
  */
23
23
  export declare function findPhaseByRef(phases: Phase[], features: Feature[], ref: string): Phase | undefined;
24
+ /**
25
+ * Resolve a task reference to { phase, task }. Harness-agnostic, shared by all
26
+ * adapters so resolution semantics are identical everywhere.
27
+ *
28
+ * Supported task refs (case-insensitive):
29
+ * - UUID: "bd6ed366-..." -> task.id
30
+ * - Short: "T003" | "t3" -> task.number (GLOBALLY unique under the
31
+ * new global-sequence numbering; bare T00x
32
+ * is unambiguous across all phases)
33
+ * - Compos: "F001/P002/T003" -> feature/phase/task numbers with parent
34
+ * "P002(F001)/T003" validation (feature + phase must match)
35
+ * "P002/T003"
36
+ * - ShortId: "UUXD1" -> task.shortId (5-char, globally unique)
37
+ * - Title: exact match, then includes (backward-compat fallback)
38
+ *
39
+ * Returns `undefined` when not found, or when a composite parent (phase/feature)
40
+ * does not match the resolved task's parent.
41
+ */
42
+ import type { Task } from "./schema.js";
43
+ export declare function findTaskByRef(phases: Phase[], features: Feature[], ref: string): {
44
+ phase: Phase;
45
+ task: Task;
46
+ } | undefined;
24
47
  //# sourceMappingURL=refs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../src/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAKlD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV,KAAK,GAAG,SAAS,CAgCnB"}
1
+ {"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../src/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAKlD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV,KAAK,GAAG,SAAS,CAgCnB;AACD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAexC,wBAAgB,aAAa,CAC3B,MAAM,EAAE,KAAK,EAAE,EACf,QAAQ,EAAE,OAAO,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAAG,SAAS,CAiD1C"}