@agent-plan/core 0.2.22 → 0.2.23-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.
- package/dist/display-status.d.ts +3 -5
- package/dist/display-status.d.ts.map +1 -1
- package/dist/display-status.js +5 -14
- package/dist/handoff-context.d.ts +60 -0
- package/dist/handoff-context.d.ts.map +1 -0
- package/dist/handoff-context.js +150 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/plan-store.d.ts +49 -9
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +376 -71
- package/dist/planner-rules.d.ts +21 -0
- package/dist/planner-rules.d.ts.map +1 -0
- package/dist/planner-rules.js +58 -0
- package/dist/read-tracking.d.ts +86 -0
- package/dist/read-tracking.d.ts.map +1 -0
- package/dist/read-tracking.js +192 -0
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +30 -24
- package/dist/schema.d.ts +551 -227
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +18 -6
- package/dist/task-selection.d.ts +38 -1
- package/dist/task-selection.d.ts.map +1 -1
- package/dist/task-selection.js +49 -11
- package/dist/task-start-outcome.d.ts +23 -0
- package/dist/task-start-outcome.d.ts.map +1 -0
- package/dist/task-start-outcome.js +23 -0
- package/package.json +1 -1
package/dist/plan-store.js
CHANGED
|
@@ -14,12 +14,26 @@ const PLANNER_GITIGNORE = [
|
|
|
14
14
|
"generated/",
|
|
15
15
|
"",
|
|
16
16
|
].join("\n");
|
|
17
|
-
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, } from "./schema.js";
|
|
17
|
+
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
|
|
18
18
|
import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
|
|
19
19
|
import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
|
|
20
|
+
import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
|
|
21
|
+
import { applyHandoffContextSync, auditPhaseHandoff, } from "./handoff-context.js";
|
|
20
22
|
function nowISO() {
|
|
21
23
|
return new Date().toISOString();
|
|
22
24
|
}
|
|
25
|
+
const MAX_SESSION_INFO_ENTRIES = 16;
|
|
26
|
+
function upsertSessionInfo(entity, sessionId, createdAt) {
|
|
27
|
+
const nextInfo = [
|
|
28
|
+
...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
|
|
29
|
+
{ sessionId, createdAt },
|
|
30
|
+
]
|
|
31
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
32
|
+
.slice(0, MAX_SESSION_INFO_ENTRIES);
|
|
33
|
+
const changed = nextInfo.length !== entity.sessionInfo.length
|
|
34
|
+
|| nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
|
|
35
|
+
return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
|
|
36
|
+
}
|
|
23
37
|
function resolveStoredFeatureId(features, ref) {
|
|
24
38
|
const raw = ref?.trim();
|
|
25
39
|
if (!raw)
|
|
@@ -417,6 +431,41 @@ export async function migrateToGlobalSequence(store) {
|
|
|
417
431
|
return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
|
|
418
432
|
});
|
|
419
433
|
}
|
|
434
|
+
/** Map legacy/removed status strings to their canonical replacements before
|
|
435
|
+
* schema validation. Canonical task status "paused" was removed from the
|
|
436
|
+
* domain; persisted entities (tasks, statusLog entries) may still carry it.
|
|
437
|
+
* Rewriting on read keeps legacy data loadable without a one-time migration
|
|
438
|
+
* pass, and the normalized value is persisted on the next save. Scoped to
|
|
439
|
+
* status-bearing fields so unrelated string values are never touched. */
|
|
440
|
+
function migrateLegacyStatuses(value) {
|
|
441
|
+
if (Array.isArray(value))
|
|
442
|
+
return value.map(migrateLegacyStatuses);
|
|
443
|
+
if (value && typeof value === "object") {
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const [key, child] of Object.entries(value)) {
|
|
446
|
+
if ((key === "status" || key === "fromStatus" || key === "toStatus") && child === "paused") {
|
|
447
|
+
out[key] = "planned";
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
out[key] = migrateLegacyStatuses(child);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
/** Walk up from `path` to find the owning `.planner` root, so crash-recovery
|
|
458
|
+
* can locate `.local/backups/<rel>.bak` without every readJson caller threading
|
|
459
|
+
* `root`. Returns undefined if no `.planner` ancestor exists. */
|
|
460
|
+
function findPlannerRoot(path) {
|
|
461
|
+
let dir = dirname(path);
|
|
462
|
+
while (dir !== dirname(dir)) {
|
|
463
|
+
if (basename(dir) === ".planner")
|
|
464
|
+
return dir;
|
|
465
|
+
dir = dirname(dir);
|
|
466
|
+
}
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
420
469
|
async function readJson(path, schema) {
|
|
421
470
|
let backupTried = false;
|
|
422
471
|
let backupFailed = false;
|
|
@@ -424,20 +473,36 @@ async function readJson(path, schema) {
|
|
|
424
473
|
try {
|
|
425
474
|
const raw = await readFile(path, "utf-8");
|
|
426
475
|
rawPreview = raw.slice(0, 240);
|
|
427
|
-
return schema.parse(JSON.parse(raw));
|
|
476
|
+
return schema.parse(migrateLegacyStatuses(JSON.parse(raw)));
|
|
428
477
|
}
|
|
429
478
|
catch (cause) {
|
|
430
|
-
//
|
|
479
|
+
// Recover from the previous-version backup before giving up:
|
|
480
|
+
// 1) legacy inline `<file>.bak` (next to the source), then
|
|
481
|
+
// 2) the relocated `.planner/.local/backups/<rel>.bak` (P043).
|
|
431
482
|
backupTried = true;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
483
|
+
const tryBackup = async (bakPath) => {
|
|
484
|
+
try {
|
|
485
|
+
const bak = await readFile(bakPath, "utf-8");
|
|
486
|
+
rawPreview = bak.slice(0, 240);
|
|
487
|
+
return schema.parse(migrateLegacyStatuses(JSON.parse(bak)));
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const inline = await tryBackup(`${path}.bak`);
|
|
494
|
+
if (inline !== undefined)
|
|
495
|
+
return inline;
|
|
496
|
+
let local;
|
|
497
|
+
const plannerRoot = findPlannerRoot(path);
|
|
498
|
+
if (plannerRoot) {
|
|
499
|
+
const rel = path.slice(plannerRoot.length).replace(/^\//, "");
|
|
500
|
+
local = await tryBackup(join(plannerRoot, ".local", "backups", rel + ".bak"));
|
|
501
|
+
}
|
|
502
|
+
if (local !== undefined)
|
|
503
|
+
return local;
|
|
504
|
+
backupFailed = true;
|
|
505
|
+
// fall through to original error
|
|
441
506
|
const details = {
|
|
442
507
|
path,
|
|
443
508
|
operation: "readJson",
|
|
@@ -518,11 +583,43 @@ export class PlanStore {
|
|
|
518
583
|
normalizeTasks(tasks) {
|
|
519
584
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
|
|
520
585
|
// Do NOT renumber here — renumbering would break references after deletions.
|
|
521
|
-
const normalized = tasks.map((task) =>
|
|
522
|
-
|
|
523
|
-
|
|
586
|
+
const normalized = tasks.map((task) => {
|
|
587
|
+
const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
|
|
588
|
+
? task.createdAt
|
|
589
|
+
: task.descriptionUpdatedAt;
|
|
590
|
+
const normalizedStatus = task.status === "paused" ? "planned" : task.status;
|
|
591
|
+
const pauseSnapshot = this.isTerminalTaskStatus(normalizedStatus) ? null : task.pauseSnapshot;
|
|
592
|
+
const statusLog = (task.statusLog ?? []).map((entry) => ({
|
|
593
|
+
...entry,
|
|
594
|
+
fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
|
|
595
|
+
toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
|
|
596
|
+
}));
|
|
597
|
+
if (descriptionUpdatedAt !== task.descriptionUpdatedAt
|
|
598
|
+
|| normalizedStatus !== task.status
|
|
599
|
+
|| pauseSnapshot !== task.pauseSnapshot
|
|
600
|
+
|| statusLog.some((entry, index) => entry !== task.statusLog[index])) {
|
|
601
|
+
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
|
|
602
|
+
}
|
|
603
|
+
return task;
|
|
604
|
+
});
|
|
524
605
|
return { tasks: normalized, changed: normalized.some((task, index) => task !== tasks[index]) };
|
|
525
606
|
}
|
|
607
|
+
isTerminalTaskStatus(status) {
|
|
608
|
+
return status === "done" || status === "canceled" || status === "rejected";
|
|
609
|
+
}
|
|
610
|
+
async pruneObsoleteWorkDeviations() {
|
|
611
|
+
const phases = await this.loadAllPhases();
|
|
612
|
+
const taskStatusById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task.status])));
|
|
613
|
+
const current = await this.loadWorkDeviations();
|
|
614
|
+
const next = current.filter((deviation) => {
|
|
615
|
+
const resumeStatus = taskStatusById.get(deviation.resumeTaskId);
|
|
616
|
+
return Boolean(resumeStatus) && !this.isTerminalTaskStatus(resumeStatus);
|
|
617
|
+
});
|
|
618
|
+
if (next.length === current.length)
|
|
619
|
+
return;
|
|
620
|
+
await this.saveWorkDeviations(next);
|
|
621
|
+
await this.refreshResume();
|
|
622
|
+
}
|
|
526
623
|
/** Stamp description edits independently from generic entity mutations.
|
|
527
624
|
* Legacy entities cannot reveal their historical description-edit time, so
|
|
528
625
|
* their creation time is the earliest truthful fallback. */
|
|
@@ -734,8 +831,28 @@ export class PlanStore {
|
|
|
734
831
|
return join(this.localRoot(), "activity.json");
|
|
735
832
|
}
|
|
736
833
|
localRoot() {
|
|
834
|
+
// Worktree-local, git-ignored runtime state. The shared-vs-local boundary
|
|
835
|
+
// and the invariants every harness relies on are documented in
|
|
836
|
+
// packages/plan-core/docs/runtime-boundaries.md.
|
|
737
837
|
return join(this.root, ".local");
|
|
738
838
|
}
|
|
839
|
+
deviationsPath() {
|
|
840
|
+
return join(this.localRoot(), "deviations.json");
|
|
841
|
+
}
|
|
842
|
+
/** Runtime work deviations, persisted in worktree-local `.local/deviations.json`
|
|
843
|
+
* (never in shared `project.json`). Falls back to `[]` on a missing/corrupt file. */
|
|
844
|
+
async loadWorkDeviations() {
|
|
845
|
+
try {
|
|
846
|
+
return await readJson(this.deviationsPath(), WorkDeviationSchema.array());
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return [];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async saveWorkDeviations(deviations) {
|
|
853
|
+
await atomicWriteJson(this.deviationsPath(), deviations, this.root);
|
|
854
|
+
await this.maybeAutoSync();
|
|
855
|
+
}
|
|
739
856
|
timestampPath() {
|
|
740
857
|
return join(this.localRoot(), "timestamp.json");
|
|
741
858
|
}
|
|
@@ -822,6 +939,9 @@ export class PlanStore {
|
|
|
822
939
|
};
|
|
823
940
|
await atomicWriteJson(this.manifestPath(), manifest, this.root);
|
|
824
941
|
await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
|
|
942
|
+
// Seed the worktree-local runtime-deviation store (T299). Empty for new
|
|
943
|
+
// projects; migrateWorkDeviations (loadProject) backfills legacy data.
|
|
944
|
+
await atomicWriteJson(this.deviationsPath(), [], this.root);
|
|
825
945
|
await this.saveProject({
|
|
826
946
|
name: projectName,
|
|
827
947
|
goal: "",
|
|
@@ -860,6 +980,11 @@ export class PlanStore {
|
|
|
860
980
|
guardBypassUntil: "",
|
|
861
981
|
});
|
|
862
982
|
await this.writeGenerated();
|
|
983
|
+
// Write the STATIC planner extension rules. Content is fixed (no timestamps,
|
|
984
|
+
// no dynamic data) so the file is identical across worktrees/branches and
|
|
985
|
+
// never causes git conflicts. The canonical source is plan-core; this file
|
|
986
|
+
// is the per-project copy / override point, loaded at planner startup.
|
|
987
|
+
await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
|
|
863
988
|
// Write a README stub
|
|
864
989
|
const readme = [
|
|
865
990
|
"# Project Plan",
|
|
@@ -884,6 +1009,15 @@ export class PlanStore {
|
|
|
884
1009
|
// - generated/: auto-regenerated markdown views (derived from JSON; churn)
|
|
885
1010
|
await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
|
|
886
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Planner extension rules — the agent-behavior contract for every project
|
|
1014
|
+
* using the extension. Loaded from the static .planner/rules.json if present,
|
|
1015
|
+
* otherwise the canonical code set. Static (no timestamps), safe across
|
|
1016
|
+
* worktrees/branches.
|
|
1017
|
+
*/
|
|
1018
|
+
async extensionRules() {
|
|
1019
|
+
return loadExtensionRules(this.root);
|
|
1020
|
+
}
|
|
887
1021
|
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
888
1022
|
* canonical transient/derived patterns). Projects initialized before the
|
|
889
1023
|
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
@@ -924,7 +1058,19 @@ export class PlanStore {
|
|
|
924
1058
|
return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
|
|
925
1059
|
}
|
|
926
1060
|
async loadProject() {
|
|
927
|
-
|
|
1061
|
+
const project = await readJson(this.projectPath(), ProjectSchema);
|
|
1062
|
+
// One-time migration (T299): move runtime workDeviations from the shared
|
|
1063
|
+
// project.json into worktree-local .local/deviations.json, then clear them
|
|
1064
|
+
// in project.json so runtime state never churns shared metadata.
|
|
1065
|
+
// Idempotent: a non-empty .local file is never overwritten.
|
|
1066
|
+
if (project.workDeviations.length > 0 && (await this.loadWorkDeviations()).length === 0) {
|
|
1067
|
+
await this.saveWorkDeviations(project.workDeviations);
|
|
1068
|
+
await atomicUpdateJson(this.projectPath(), ProjectSchema, (p) => ({ ...p, workDeviations: [] }), this.root);
|
|
1069
|
+
}
|
|
1070
|
+
// Read-view merge: readers keep using `project.workDeviations`, but the
|
|
1071
|
+
// values now come from worktree-local storage.
|
|
1072
|
+
const deviations = await this.loadWorkDeviations();
|
|
1073
|
+
return { ...project, workDeviations: deviations };
|
|
928
1074
|
}
|
|
929
1075
|
/**
|
|
930
1076
|
* Reserve immutable human identifiers without touching tracked `project.json`.
|
|
@@ -1144,12 +1290,13 @@ export class PlanStore {
|
|
|
1144
1290
|
async refreshResume(notes, lastSessionSummary) {
|
|
1145
1291
|
const workspace = await this.loadAll();
|
|
1146
1292
|
const inProgressPhases = workspace.phases.filter((p) => p.status === "in-progress");
|
|
1293
|
+
const activePhase = workspace.phases.find((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
1147
1294
|
const inProgressTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "in-progress"));
|
|
1148
1295
|
const blockedTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "blocked"));
|
|
1149
1296
|
const existing = await this.loadResume();
|
|
1150
1297
|
const resume = {
|
|
1151
1298
|
updatedAt: nowISO(),
|
|
1152
|
-
currentPhaseId:
|
|
1299
|
+
currentPhaseId: activePhase?.id ?? (existing?.currentPhaseId || inProgressPhases[0]?.id || ""),
|
|
1153
1300
|
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
1154
1301
|
nextSteps: existing?.nextSteps ?? [],
|
|
1155
1302
|
nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
|
|
@@ -1362,6 +1509,45 @@ export class PlanStore {
|
|
|
1362
1509
|
catch { /* ignore */ }
|
|
1363
1510
|
}
|
|
1364
1511
|
}
|
|
1512
|
+
const backupsRoot = join(this.root, ".local", "backups");
|
|
1513
|
+
const walkBackups = async (dir) => {
|
|
1514
|
+
let entries = [];
|
|
1515
|
+
try {
|
|
1516
|
+
entries = await readdir(dir);
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
for (const name of entries) {
|
|
1522
|
+
const full = join(dir, name);
|
|
1523
|
+
let st;
|
|
1524
|
+
try {
|
|
1525
|
+
st = await stat(full);
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (st.isDirectory()) {
|
|
1531
|
+
await walkBackups(full);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (!name.endsWith(".json.bak"))
|
|
1535
|
+
continue;
|
|
1536
|
+
const rel = full.slice(backupsRoot.length).replace(/^\//, "");
|
|
1537
|
+
const mainPath = join(this.root, rel.slice(0, -".bak".length));
|
|
1538
|
+
try {
|
|
1539
|
+
await stat(mainPath);
|
|
1540
|
+
}
|
|
1541
|
+
catch {
|
|
1542
|
+
try {
|
|
1543
|
+
await unlink(full);
|
|
1544
|
+
removed += 1;
|
|
1545
|
+
}
|
|
1546
|
+
catch { /* ignore */ }
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
await walkBackups(backupsRoot);
|
|
1365
1551
|
}
|
|
1366
1552
|
catch { /* best-effort */ }
|
|
1367
1553
|
return { removed };
|
|
@@ -1592,18 +1778,13 @@ export class PlanStore {
|
|
|
1592
1778
|
return "done";
|
|
1593
1779
|
const hasDone = meaningful.some((s) => s === "done");
|
|
1594
1780
|
const hasActive = meaningful.some((s) => s === "in-progress");
|
|
1595
|
-
const hasPaused = meaningful.some((s) => s === "paused");
|
|
1596
1781
|
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1597
1782
|
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1598
1783
|
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1599
1784
|
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1600
1785
|
if (hasActive)
|
|
1601
1786
|
return "in-progress";
|
|
1602
|
-
|
|
1603
|
-
// or deferred, do not imply active execution.
|
|
1604
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasDeferred && hasPaused)
|
|
1605
|
-
return "paused";
|
|
1606
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasPaused && hasDeferred)
|
|
1787
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1607
1788
|
return "deferred";
|
|
1608
1789
|
// Partial completion with remaining planned/blocked/waiting work still means
|
|
1609
1790
|
// the phase has genuinely started and is not terminal yet.
|
|
@@ -1616,8 +1797,6 @@ export class PlanStore {
|
|
|
1616
1797
|
return "waiting";
|
|
1617
1798
|
if (hasDeferred)
|
|
1618
1799
|
return "deferred";
|
|
1619
|
-
if (hasPaused)
|
|
1620
|
-
return "paused";
|
|
1621
1800
|
return "planned";
|
|
1622
1801
|
}
|
|
1623
1802
|
deriveFeatureStatus(featureId, phases) {
|
|
@@ -1633,17 +1812,13 @@ export class PlanStore {
|
|
|
1633
1812
|
return "done";
|
|
1634
1813
|
const hasDone = meaningful.some((s) => s === "done");
|
|
1635
1814
|
const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
|
|
1636
|
-
const hasPaused = meaningful.some((s) => s === "paused");
|
|
1637
1815
|
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1638
1816
|
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1639
1817
|
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1640
1818
|
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1641
1819
|
if (hasActive)
|
|
1642
1820
|
return "in-progress";
|
|
1643
|
-
|
|
1644
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasDeferred && hasPaused)
|
|
1645
|
-
return "paused";
|
|
1646
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasPaused && hasDeferred)
|
|
1821
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1647
1822
|
return "deferred";
|
|
1648
1823
|
if (hasDone)
|
|
1649
1824
|
return "in-progress";
|
|
@@ -1654,8 +1829,6 @@ export class PlanStore {
|
|
|
1654
1829
|
return "waiting";
|
|
1655
1830
|
if (hasDeferred)
|
|
1656
1831
|
return "deferred";
|
|
1657
|
-
if (hasPaused)
|
|
1658
|
-
return "paused";
|
|
1659
1832
|
return "planned";
|
|
1660
1833
|
}
|
|
1661
1834
|
async syncStatuses() {
|
|
@@ -1740,24 +1913,23 @@ export class PlanStore {
|
|
|
1740
1913
|
}
|
|
1741
1914
|
/** Persist an explicitly approved work deviation without coupling it to a harness. */
|
|
1742
1915
|
async addWorkDeviation(deviation) {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
}));
|
|
1916
|
+
const deviations = [...(await this.loadWorkDeviations()), deviation];
|
|
1917
|
+
await this.saveWorkDeviations(deviations);
|
|
1918
|
+
return this.loadProject();
|
|
1747
1919
|
}
|
|
1748
1920
|
/** Advance a deviation while retaining its complete audit and return stack. */
|
|
1749
1921
|
async setWorkDeviationState(id, state, timestamp = nowISO()) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1922
|
+
const deviations = await this.loadWorkDeviations();
|
|
1923
|
+
const next = deviations.map((deviation) => deviation.id !== id ? deviation : {
|
|
1924
|
+
...deviation,
|
|
1925
|
+
state,
|
|
1926
|
+
activatedAt: state === "active" ? timestamp : deviation.activatedAt,
|
|
1927
|
+
resumeRequiredAt: state === "resume-required" ? timestamp : deviation.resumeRequiredAt,
|
|
1928
|
+
resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
|
|
1929
|
+
resumedAt: state === "resumed" ? timestamp : deviation.resumedAt,
|
|
1930
|
+
});
|
|
1931
|
+
await this.saveWorkDeviations(next);
|
|
1932
|
+
return this.loadProject();
|
|
1761
1933
|
}
|
|
1762
1934
|
async updateFeatures(updater) {
|
|
1763
1935
|
const updated = await this.withFeaturesLock(async () => {
|
|
@@ -1783,8 +1955,94 @@ export class PlanStore {
|
|
|
1783
1955
|
await this.maybeAutoSync();
|
|
1784
1956
|
return updated;
|
|
1785
1957
|
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Persist completion of one full task → phase → feature → requirements read
|
|
1960
|
+
* sequence. Session metadata is deliberately the only changed entity field:
|
|
1961
|
+
* semantic updatedAt/descriptionUpdatedAt values are preserved exactly.
|
|
1962
|
+
*/
|
|
1963
|
+
async recordContextRead(input) {
|
|
1964
|
+
const sessionId = input.sessionId.trim();
|
|
1965
|
+
if (!sessionId)
|
|
1966
|
+
throw new PlanStoreError("Context-read attestation requires a non-empty sessionId.");
|
|
1967
|
+
const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
|
|
1968
|
+
const requirementIds = [...new Set((input.requirementIds ?? []).map((id) => id.trim()).filter(Boolean))];
|
|
1969
|
+
return this.runAsBatch(async () => {
|
|
1970
|
+
const originalPhase = await this.loadPhase(input.phaseId);
|
|
1971
|
+
const taskIndex = originalPhase.tasks.findIndex((task) => task.id === input.taskId);
|
|
1972
|
+
if (taskIndex < 0)
|
|
1973
|
+
throw new PlanStoreError(`Task ${input.taskId} does not belong to phase ${input.phaseId}.`);
|
|
1974
|
+
const originalFeatures = await this.loadFeatures();
|
|
1975
|
+
const featureId = input.featureId?.trim() || originalPhase.featureId;
|
|
1976
|
+
const featureIndex = featureId
|
|
1977
|
+
? originalFeatures.features.findIndex((feature) => feature.id === featureId)
|
|
1978
|
+
: -1;
|
|
1979
|
+
if (featureId && featureIndex < 0)
|
|
1980
|
+
throw new PlanStoreError(`Feature ${featureId} was not found for context-read attestation.`);
|
|
1981
|
+
const originalRequirements = await this.loadRequirements();
|
|
1982
|
+
const missingRequirement = requirementIds.find((id) => !originalRequirements.requirements.some((requirement) => requirement.id === id));
|
|
1983
|
+
if (missingRequirement)
|
|
1984
|
+
throw new PlanStoreError(`Requirement ${missingRequirement} was not found for context-read attestation.`);
|
|
1985
|
+
const nextPhase = structuredClone(originalPhase);
|
|
1986
|
+
const nextTask = upsertSessionInfo(nextPhase.tasks[taskIndex], sessionId, createdAt);
|
|
1987
|
+
nextPhase.tasks[taskIndex] = nextTask.entity;
|
|
1988
|
+
const nextPhaseInfo = upsertSessionInfo(nextPhase, sessionId, createdAt);
|
|
1989
|
+
const phaseChanged = nextTask.changed || nextPhaseInfo.changed;
|
|
1990
|
+
let nextFeature;
|
|
1991
|
+
let featureChanged = false;
|
|
1992
|
+
if (featureIndex >= 0) {
|
|
1993
|
+
const featureResult = upsertSessionInfo(structuredClone(originalFeatures.features[featureIndex]), sessionId, createdAt);
|
|
1994
|
+
nextFeature = featureResult.entity;
|
|
1995
|
+
featureChanged = featureResult.changed;
|
|
1996
|
+
}
|
|
1997
|
+
const nextRequirements = structuredClone(originalRequirements);
|
|
1998
|
+
let requirementsChanged = false;
|
|
1999
|
+
for (const requirement of nextRequirements.requirements) {
|
|
2000
|
+
if (!requirementIds.includes(requirement.id))
|
|
2001
|
+
continue;
|
|
2002
|
+
const result = upsertSessionInfo(requirement, sessionId, createdAt);
|
|
2003
|
+
if (result.changed) {
|
|
2004
|
+
Object.assign(requirement, result.entity);
|
|
2005
|
+
requirementsChanged = true;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (!phaseChanged && !featureChanged && !requirementsChanged) {
|
|
2009
|
+
return {
|
|
2010
|
+
phase: originalPhase,
|
|
2011
|
+
...(featureIndex >= 0 ? { feature: originalFeatures.features[featureIndex] } : {}),
|
|
2012
|
+
requirements: originalRequirements.requirements.filter((requirement) => requirementIds.includes(requirement.id)),
|
|
2013
|
+
createdAt,
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
try {
|
|
2017
|
+
if (featureChanged && nextFeature)
|
|
2018
|
+
await this.saveFeature(nextFeature);
|
|
2019
|
+
if (phaseChanged)
|
|
2020
|
+
await this.savePhase(nextPhaseInfo.entity);
|
|
2021
|
+
if (requirementsChanged)
|
|
2022
|
+
await this.saveRequirements(nextRequirements);
|
|
2023
|
+
}
|
|
2024
|
+
catch (error) {
|
|
2025
|
+
const rollbackErrors = [];
|
|
2026
|
+
if (featureChanged && nextFeature)
|
|
2027
|
+
await this.saveFeature(originalFeatures.features[featureIndex]).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2028
|
+
if (phaseChanged)
|
|
2029
|
+
await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2030
|
+
if (requirementsChanged)
|
|
2031
|
+
await this.saveRequirements(originalRequirements).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2032
|
+
if (rollbackErrors.length > 0)
|
|
2033
|
+
throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
|
|
2034
|
+
throw error;
|
|
2035
|
+
}
|
|
2036
|
+
const phase = await this.loadPhase(input.phaseId);
|
|
2037
|
+
const feature = featureIndex >= 0 ? (await this.loadFeatures()).features.find((candidate) => candidate.id === featureId) : undefined;
|
|
2038
|
+
const requirements = (await this.loadRequirements()).requirements.filter((requirement) => requirementIds.includes(requirement.id));
|
|
2039
|
+
return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
1786
2042
|
async saveProject(project) {
|
|
1787
|
-
|
|
2043
|
+
// Runtime workDeviations live in .local/deviations.json (T299); never
|
|
2044
|
+
// persist them here so shared project.json stays stable across worktrees.
|
|
2045
|
+
const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
|
|
1788
2046
|
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
1789
2047
|
await this.touchTimestamp();
|
|
1790
2048
|
await this.maybeAutoSync();
|
|
@@ -1896,11 +2154,12 @@ export class PlanStore {
|
|
|
1896
2154
|
? { ...timestamped, featureId: resolvedFeatureId }
|
|
1897
2155
|
: timestamped;
|
|
1898
2156
|
return this.normalizePhaseDocument(normalizedInput).phase;
|
|
1899
|
-
});
|
|
2157
|
+
}, this.root);
|
|
2158
|
+
await this.pruneObsoleteWorkDeviations();
|
|
1900
2159
|
await this.maybeAutoSync();
|
|
1901
2160
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
1902
2161
|
}
|
|
1903
|
-
/**
|
|
2162
|
+
/** Save a durable resume checkpoint without introducing a separate canonical task status. */
|
|
1904
2163
|
async pauseTask(phaseId, taskId, input) {
|
|
1905
2164
|
const snapshot = TaskPauseSnapshotSchema.parse(input);
|
|
1906
2165
|
let paused;
|
|
@@ -1909,7 +2168,7 @@ export class PlanStore {
|
|
|
1909
2168
|
if (!task)
|
|
1910
2169
|
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
1911
2170
|
if (task.status !== "in-progress") {
|
|
1912
|
-
throw new PlanStoreError(`Task ${taskId} cannot be
|
|
2171
|
+
throw new PlanStoreError(`Task ${taskId} cannot be checkpointed from ${task.status}; only in-progress work can capture a pause snapshot.`);
|
|
1913
2172
|
}
|
|
1914
2173
|
const description = [
|
|
1915
2174
|
`Reason: ${snapshot.reason}`,
|
|
@@ -1919,15 +2178,15 @@ export class PlanStore {
|
|
|
1919
2178
|
].join("\n");
|
|
1920
2179
|
paused = {
|
|
1921
2180
|
...task,
|
|
1922
|
-
status: "
|
|
2181
|
+
status: "planned",
|
|
1923
2182
|
pauseSnapshot: snapshot,
|
|
1924
2183
|
pauseHistory: [...task.pauseHistory, snapshot],
|
|
1925
2184
|
statusLog: [...task.statusLog, {
|
|
1926
2185
|
id: createStatusLogEntryId(),
|
|
1927
2186
|
date: snapshot.pausedAt,
|
|
1928
2187
|
fromStatus: "in-progress",
|
|
1929
|
-
toStatus: "
|
|
1930
|
-
title: "in-progress →
|
|
2188
|
+
toStatus: "planned",
|
|
2189
|
+
title: "in-progress → planned (checkpoint saved)",
|
|
1931
2190
|
description,
|
|
1932
2191
|
}],
|
|
1933
2192
|
updatedAt: snapshot.pausedAt,
|
|
@@ -1937,15 +2196,18 @@ export class PlanStore {
|
|
|
1937
2196
|
});
|
|
1938
2197
|
return paused;
|
|
1939
2198
|
}
|
|
1940
|
-
/** Resume a
|
|
2199
|
+
/** Resume a checkpointed task without resetting its original startedAt. */
|
|
1941
2200
|
async resumeTask(phaseId, taskId, timestamp = nowISO()) {
|
|
1942
2201
|
let resumed;
|
|
1943
2202
|
await this.updatePhase(phaseId, (phase) => {
|
|
1944
2203
|
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
1945
2204
|
if (!task)
|
|
1946
2205
|
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
1947
|
-
if (task.
|
|
1948
|
-
throw new PlanStoreError(`Task ${taskId}
|
|
2206
|
+
if (!task.pauseSnapshot) {
|
|
2207
|
+
throw new PlanStoreError(`Task ${taskId} has no saved checkpoint to resume.`);
|
|
2208
|
+
}
|
|
2209
|
+
if (task.status === "done" || task.status === "canceled" || task.status === "rejected") {
|
|
2210
|
+
throw new PlanStoreError(`Task ${taskId} cannot be resumed from ${task.status}.`);
|
|
1949
2211
|
}
|
|
1950
2212
|
const snapshot = task.pauseSnapshot;
|
|
1951
2213
|
resumed = {
|
|
@@ -1956,12 +2218,10 @@ export class PlanStore {
|
|
|
1956
2218
|
statusLog: [...task.statusLog, {
|
|
1957
2219
|
id: createStatusLogEntryId(),
|
|
1958
2220
|
date: timestamp,
|
|
1959
|
-
fromStatus:
|
|
2221
|
+
fromStatus: task.status,
|
|
1960
2222
|
toStatus: "in-progress",
|
|
1961
|
-
title:
|
|
1962
|
-
description: snapshot
|
|
1963
|
-
? `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`
|
|
1964
|
-
: "Paused task resumed.",
|
|
2223
|
+
title: `${task.status} → in-progress (resume checkpoint)`,
|
|
2224
|
+
description: `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`,
|
|
1965
2225
|
}],
|
|
1966
2226
|
updatedAt: timestamp,
|
|
1967
2227
|
};
|
|
@@ -1975,6 +2235,51 @@ export class PlanStore {
|
|
|
1975
2235
|
async getPhaseHandoff(phaseId) {
|
|
1976
2236
|
return (await this.loadPhase(phaseId)).handoff;
|
|
1977
2237
|
}
|
|
2238
|
+
/** Audit one exact phase before preparing a handoff refresh. */
|
|
2239
|
+
async preparePhaseHandoff(phaseId) {
|
|
2240
|
+
const phase = await this.loadPhase(phaseId);
|
|
2241
|
+
if (!phase.featureId)
|
|
2242
|
+
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
2243
|
+
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === phase.featureId);
|
|
2244
|
+
if (!feature)
|
|
2245
|
+
throw new PlanStoreError(`Parent feature ${phase.featureId} not found for phase ${phaseId}.`);
|
|
2246
|
+
return auditPhaseHandoff(phase, feature);
|
|
2247
|
+
}
|
|
2248
|
+
/** Refresh the single active handoff and synchronize durable task/phase/feature
|
|
2249
|
+
* context. Unlike setPhaseHandoff(), this deliberately does not archive the
|
|
2250
|
+
* previous active body: callers must reconcile it using the optimistic
|
|
2251
|
+
* handoffUpdatedAt token returned by preparePhaseHandoff(). */
|
|
2252
|
+
async refreshPhaseHandoff(phaseId, input) {
|
|
2253
|
+
return this.runAsBatch(async () => {
|
|
2254
|
+
const originalPhase = await this.loadPhase(phaseId);
|
|
2255
|
+
if (!originalPhase.featureId)
|
|
2256
|
+
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
2257
|
+
const originalFeatures = await this.loadFeatures();
|
|
2258
|
+
const featureIndex = originalFeatures.features.findIndex((candidate) => candidate.id === originalPhase.featureId);
|
|
2259
|
+
if (featureIndex < 0)
|
|
2260
|
+
throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
|
|
2261
|
+
const timestamp = nowISO();
|
|
2262
|
+
const applied = applyHandoffContextSync(originalPhase, originalFeatures.features[featureIndex], input, timestamp);
|
|
2263
|
+
const nextFeatures = structuredClone(originalFeatures);
|
|
2264
|
+
nextFeatures.features[featureIndex] = applied.feature;
|
|
2265
|
+
try {
|
|
2266
|
+
await this.saveFeatures(nextFeatures);
|
|
2267
|
+
await this.savePhase(applied.phase);
|
|
2268
|
+
}
|
|
2269
|
+
catch (error) {
|
|
2270
|
+
const rollbackErrors = [];
|
|
2271
|
+
await this.saveFeatures(originalFeatures).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2272
|
+
await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2273
|
+
if (rollbackErrors.length > 0) {
|
|
2274
|
+
throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
|
|
2275
|
+
}
|
|
2276
|
+
throw error;
|
|
2277
|
+
}
|
|
2278
|
+
const phase = await this.loadPhase(phaseId);
|
|
2279
|
+
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
|
|
2280
|
+
return { phase, feature, updatedTaskIds: applied.updatedTaskIds, handoffUpdatedAt: phase.handoffUpdatedAt };
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
1978
2283
|
/** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
|
|
1979
2284
|
* canceled phase cannot receive a new operational handoff. Replacing an
|
|
1980
2285
|
* existing handoff archives the previous content as `superseded` first. */
|
|
@@ -2186,16 +2491,16 @@ export class PlanStore {
|
|
|
2186
2491
|
await this.unlinkPhaseFiles(phaseId);
|
|
2187
2492
|
await this.touchTimestamp();
|
|
2188
2493
|
}
|
|
2189
|
-
/** Remove a phase file
|
|
2190
|
-
*
|
|
2191
|
-
*
|
|
2192
|
-
*
|
|
2193
|
-
*
|
|
2194
|
-
* .local/backups/ and are never read by readJson, so only the inline .bak
|
|
2195
|
-
* needs removing here. */
|
|
2494
|
+
/** Remove a phase file and BOTH of its previous-version backups: the legacy
|
|
2495
|
+
* inline `phases/<id>.json.bak` (kept for backward compat) and the relocated
|
|
2496
|
+
* `.local/backups/phases/<id>.json.bak` (written once updatePhase passes root,
|
|
2497
|
+
* per P043). Leaving either behind would RESURRECT the deleted phase on the
|
|
2498
|
+
* next read, since readJson falls back to both locations. */
|
|
2196
2499
|
async unlinkPhaseFiles(phaseId) {
|
|
2197
|
-
|
|
2198
|
-
await unlink(
|
|
2500
|
+
const phaseFile = this.phasePath(phaseId);
|
|
2501
|
+
await unlink(phaseFile).catch(() => { });
|
|
2502
|
+
await unlink(`${phaseFile}.bak`).catch(() => { });
|
|
2503
|
+
await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
|
|
2199
2504
|
}
|
|
2200
2505
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
2201
2506
|
/** Load the full workspace (manifest + phases + project + requirements + features) */
|