@agent-plan/core 0.2.19-next.1 → 0.2.19-next.11
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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/plan-store.d.ts +80 -12
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +277 -92
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +21 -7
- package/dist/refs.d.ts +23 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +66 -0
- package/dist/schema.d.ts +139 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +28 -0
- package/package.json +1 -1
package/dist/plan-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, copyFile, mkdir, readdir, readFile, rename,
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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.
|
|
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
|
-
/**
|
|
1243
|
-
*
|
|
1244
|
-
|
|
1245
|
-
|
|
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));
|
package/dist/recap.d.ts.map
CHANGED
|
@@ -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,
|
|
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:
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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)
|
|
85
|
-
: `→ Read the relevant one with ${handoffShowCmd} <ref> (validate against current state)
|
|
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
|
package/dist/refs.d.ts.map
CHANGED
|
@@ -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"}
|