@agent-plan/core 0.2.22 → 0.2.23
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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/plan-store.d.ts +21 -9
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +234 -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 +49 -0
- package/dist/read-tracking.d.ts.map +1 -0
- package/dist/read-tracking.js +106 -0
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +30 -24
- package/dist/schema.d.ts +227 -227
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +6 -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/package.json +1 -1
package/dist/plan-store.js
CHANGED
|
@@ -14,9 +14,10 @@ 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";
|
|
20
21
|
function nowISO() {
|
|
21
22
|
return new Date().toISOString();
|
|
22
23
|
}
|
|
@@ -417,6 +418,41 @@ export async function migrateToGlobalSequence(store) {
|
|
|
417
418
|
return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
|
|
418
419
|
});
|
|
419
420
|
}
|
|
421
|
+
/** Map legacy/removed status strings to their canonical replacements before
|
|
422
|
+
* schema validation. Canonical task status "paused" was removed from the
|
|
423
|
+
* domain; persisted entities (tasks, statusLog entries) may still carry it.
|
|
424
|
+
* Rewriting on read keeps legacy data loadable without a one-time migration
|
|
425
|
+
* pass, and the normalized value is persisted on the next save. Scoped to
|
|
426
|
+
* status-bearing fields so unrelated string values are never touched. */
|
|
427
|
+
function migrateLegacyStatuses(value) {
|
|
428
|
+
if (Array.isArray(value))
|
|
429
|
+
return value.map(migrateLegacyStatuses);
|
|
430
|
+
if (value && typeof value === "object") {
|
|
431
|
+
const out = {};
|
|
432
|
+
for (const [key, child] of Object.entries(value)) {
|
|
433
|
+
if ((key === "status" || key === "fromStatus" || key === "toStatus") && child === "paused") {
|
|
434
|
+
out[key] = "planned";
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
out[key] = migrateLegacyStatuses(child);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
/** Walk up from `path` to find the owning `.planner` root, so crash-recovery
|
|
445
|
+
* can locate `.local/backups/<rel>.bak` without every readJson caller threading
|
|
446
|
+
* `root`. Returns undefined if no `.planner` ancestor exists. */
|
|
447
|
+
function findPlannerRoot(path) {
|
|
448
|
+
let dir = dirname(path);
|
|
449
|
+
while (dir !== dirname(dir)) {
|
|
450
|
+
if (basename(dir) === ".planner")
|
|
451
|
+
return dir;
|
|
452
|
+
dir = dirname(dir);
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
420
456
|
async function readJson(path, schema) {
|
|
421
457
|
let backupTried = false;
|
|
422
458
|
let backupFailed = false;
|
|
@@ -424,20 +460,36 @@ async function readJson(path, schema) {
|
|
|
424
460
|
try {
|
|
425
461
|
const raw = await readFile(path, "utf-8");
|
|
426
462
|
rawPreview = raw.slice(0, 240);
|
|
427
|
-
return schema.parse(JSON.parse(raw));
|
|
463
|
+
return schema.parse(migrateLegacyStatuses(JSON.parse(raw)));
|
|
428
464
|
}
|
|
429
465
|
catch (cause) {
|
|
430
|
-
//
|
|
466
|
+
// Recover from the previous-version backup before giving up:
|
|
467
|
+
// 1) legacy inline `<file>.bak` (next to the source), then
|
|
468
|
+
// 2) the relocated `.planner/.local/backups/<rel>.bak` (P043).
|
|
431
469
|
backupTried = true;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
470
|
+
const tryBackup = async (bakPath) => {
|
|
471
|
+
try {
|
|
472
|
+
const bak = await readFile(bakPath, "utf-8");
|
|
473
|
+
rawPreview = bak.slice(0, 240);
|
|
474
|
+
return schema.parse(migrateLegacyStatuses(JSON.parse(bak)));
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
const inline = await tryBackup(`${path}.bak`);
|
|
481
|
+
if (inline !== undefined)
|
|
482
|
+
return inline;
|
|
483
|
+
let local;
|
|
484
|
+
const plannerRoot = findPlannerRoot(path);
|
|
485
|
+
if (plannerRoot) {
|
|
486
|
+
const rel = path.slice(plannerRoot.length).replace(/^\//, "");
|
|
487
|
+
local = await tryBackup(join(plannerRoot, ".local", "backups", rel + ".bak"));
|
|
488
|
+
}
|
|
489
|
+
if (local !== undefined)
|
|
490
|
+
return local;
|
|
491
|
+
backupFailed = true;
|
|
492
|
+
// fall through to original error
|
|
441
493
|
const details = {
|
|
442
494
|
path,
|
|
443
495
|
operation: "readJson",
|
|
@@ -518,11 +570,43 @@ export class PlanStore {
|
|
|
518
570
|
normalizeTasks(tasks) {
|
|
519
571
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
|
|
520
572
|
// Do NOT renumber here — renumbering would break references after deletions.
|
|
521
|
-
const normalized = tasks.map((task) =>
|
|
522
|
-
|
|
523
|
-
|
|
573
|
+
const normalized = tasks.map((task) => {
|
|
574
|
+
const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
|
|
575
|
+
? task.createdAt
|
|
576
|
+
: task.descriptionUpdatedAt;
|
|
577
|
+
const normalizedStatus = task.status === "paused" ? "planned" : task.status;
|
|
578
|
+
const pauseSnapshot = this.isTerminalTaskStatus(normalizedStatus) ? null : task.pauseSnapshot;
|
|
579
|
+
const statusLog = (task.statusLog ?? []).map((entry) => ({
|
|
580
|
+
...entry,
|
|
581
|
+
fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
|
|
582
|
+
toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
|
|
583
|
+
}));
|
|
584
|
+
if (descriptionUpdatedAt !== task.descriptionUpdatedAt
|
|
585
|
+
|| normalizedStatus !== task.status
|
|
586
|
+
|| pauseSnapshot !== task.pauseSnapshot
|
|
587
|
+
|| statusLog.some((entry, index) => entry !== task.statusLog[index])) {
|
|
588
|
+
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
|
|
589
|
+
}
|
|
590
|
+
return task;
|
|
591
|
+
});
|
|
524
592
|
return { tasks: normalized, changed: normalized.some((task, index) => task !== tasks[index]) };
|
|
525
593
|
}
|
|
594
|
+
isTerminalTaskStatus(status) {
|
|
595
|
+
return status === "done" || status === "canceled" || status === "rejected";
|
|
596
|
+
}
|
|
597
|
+
async pruneObsoleteWorkDeviations() {
|
|
598
|
+
const phases = await this.loadAllPhases();
|
|
599
|
+
const taskStatusById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task.status])));
|
|
600
|
+
const current = await this.loadWorkDeviations();
|
|
601
|
+
const next = current.filter((deviation) => {
|
|
602
|
+
const resumeStatus = taskStatusById.get(deviation.resumeTaskId);
|
|
603
|
+
return Boolean(resumeStatus) && !this.isTerminalTaskStatus(resumeStatus);
|
|
604
|
+
});
|
|
605
|
+
if (next.length === current.length)
|
|
606
|
+
return;
|
|
607
|
+
await this.saveWorkDeviations(next);
|
|
608
|
+
await this.refreshResume();
|
|
609
|
+
}
|
|
526
610
|
/** Stamp description edits independently from generic entity mutations.
|
|
527
611
|
* Legacy entities cannot reveal their historical description-edit time, so
|
|
528
612
|
* their creation time is the earliest truthful fallback. */
|
|
@@ -734,8 +818,28 @@ export class PlanStore {
|
|
|
734
818
|
return join(this.localRoot(), "activity.json");
|
|
735
819
|
}
|
|
736
820
|
localRoot() {
|
|
821
|
+
// Worktree-local, git-ignored runtime state. The shared-vs-local boundary
|
|
822
|
+
// and the invariants every harness relies on are documented in
|
|
823
|
+
// packages/plan-core/docs/runtime-boundaries.md.
|
|
737
824
|
return join(this.root, ".local");
|
|
738
825
|
}
|
|
826
|
+
deviationsPath() {
|
|
827
|
+
return join(this.localRoot(), "deviations.json");
|
|
828
|
+
}
|
|
829
|
+
/** Runtime work deviations, persisted in worktree-local `.local/deviations.json`
|
|
830
|
+
* (never in shared `project.json`). Falls back to `[]` on a missing/corrupt file. */
|
|
831
|
+
async loadWorkDeviations() {
|
|
832
|
+
try {
|
|
833
|
+
return await readJson(this.deviationsPath(), WorkDeviationSchema.array());
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return [];
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async saveWorkDeviations(deviations) {
|
|
840
|
+
await atomicWriteJson(this.deviationsPath(), deviations, this.root);
|
|
841
|
+
await this.maybeAutoSync();
|
|
842
|
+
}
|
|
739
843
|
timestampPath() {
|
|
740
844
|
return join(this.localRoot(), "timestamp.json");
|
|
741
845
|
}
|
|
@@ -822,6 +926,9 @@ export class PlanStore {
|
|
|
822
926
|
};
|
|
823
927
|
await atomicWriteJson(this.manifestPath(), manifest, this.root);
|
|
824
928
|
await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
|
|
929
|
+
// Seed the worktree-local runtime-deviation store (T299). Empty for new
|
|
930
|
+
// projects; migrateWorkDeviations (loadProject) backfills legacy data.
|
|
931
|
+
await atomicWriteJson(this.deviationsPath(), [], this.root);
|
|
825
932
|
await this.saveProject({
|
|
826
933
|
name: projectName,
|
|
827
934
|
goal: "",
|
|
@@ -860,6 +967,11 @@ export class PlanStore {
|
|
|
860
967
|
guardBypassUntil: "",
|
|
861
968
|
});
|
|
862
969
|
await this.writeGenerated();
|
|
970
|
+
// Write the STATIC planner extension rules. Content is fixed (no timestamps,
|
|
971
|
+
// no dynamic data) so the file is identical across worktrees/branches and
|
|
972
|
+
// never causes git conflicts. The canonical source is plan-core; this file
|
|
973
|
+
// is the per-project copy / override point, loaded at planner startup.
|
|
974
|
+
await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
|
|
863
975
|
// Write a README stub
|
|
864
976
|
const readme = [
|
|
865
977
|
"# Project Plan",
|
|
@@ -884,6 +996,15 @@ export class PlanStore {
|
|
|
884
996
|
// - generated/: auto-regenerated markdown views (derived from JSON; churn)
|
|
885
997
|
await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
|
|
886
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* Planner extension rules — the agent-behavior contract for every project
|
|
1001
|
+
* using the extension. Loaded from the static .planner/rules.json if present,
|
|
1002
|
+
* otherwise the canonical code set. Static (no timestamps), safe across
|
|
1003
|
+
* worktrees/branches.
|
|
1004
|
+
*/
|
|
1005
|
+
async extensionRules() {
|
|
1006
|
+
return loadExtensionRules(this.root);
|
|
1007
|
+
}
|
|
887
1008
|
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
888
1009
|
* canonical transient/derived patterns). Projects initialized before the
|
|
889
1010
|
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
@@ -924,7 +1045,19 @@ export class PlanStore {
|
|
|
924
1045
|
return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
|
|
925
1046
|
}
|
|
926
1047
|
async loadProject() {
|
|
927
|
-
|
|
1048
|
+
const project = await readJson(this.projectPath(), ProjectSchema);
|
|
1049
|
+
// One-time migration (T299): move runtime workDeviations from the shared
|
|
1050
|
+
// project.json into worktree-local .local/deviations.json, then clear them
|
|
1051
|
+
// in project.json so runtime state never churns shared metadata.
|
|
1052
|
+
// Idempotent: a non-empty .local file is never overwritten.
|
|
1053
|
+
if (project.workDeviations.length > 0 && (await this.loadWorkDeviations()).length === 0) {
|
|
1054
|
+
await this.saveWorkDeviations(project.workDeviations);
|
|
1055
|
+
await atomicUpdateJson(this.projectPath(), ProjectSchema, (p) => ({ ...p, workDeviations: [] }), this.root);
|
|
1056
|
+
}
|
|
1057
|
+
// Read-view merge: readers keep using `project.workDeviations`, but the
|
|
1058
|
+
// values now come from worktree-local storage.
|
|
1059
|
+
const deviations = await this.loadWorkDeviations();
|
|
1060
|
+
return { ...project, workDeviations: deviations };
|
|
928
1061
|
}
|
|
929
1062
|
/**
|
|
930
1063
|
* Reserve immutable human identifiers without touching tracked `project.json`.
|
|
@@ -1144,12 +1277,13 @@ export class PlanStore {
|
|
|
1144
1277
|
async refreshResume(notes, lastSessionSummary) {
|
|
1145
1278
|
const workspace = await this.loadAll();
|
|
1146
1279
|
const inProgressPhases = workspace.phases.filter((p) => p.status === "in-progress");
|
|
1280
|
+
const activePhase = workspace.phases.find((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
1147
1281
|
const inProgressTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "in-progress"));
|
|
1148
1282
|
const blockedTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "blocked"));
|
|
1149
1283
|
const existing = await this.loadResume();
|
|
1150
1284
|
const resume = {
|
|
1151
1285
|
updatedAt: nowISO(),
|
|
1152
|
-
currentPhaseId:
|
|
1286
|
+
currentPhaseId: activePhase?.id ?? (existing?.currentPhaseId || inProgressPhases[0]?.id || ""),
|
|
1153
1287
|
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
1154
1288
|
nextSteps: existing?.nextSteps ?? [],
|
|
1155
1289
|
nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
|
|
@@ -1362,6 +1496,45 @@ export class PlanStore {
|
|
|
1362
1496
|
catch { /* ignore */ }
|
|
1363
1497
|
}
|
|
1364
1498
|
}
|
|
1499
|
+
const backupsRoot = join(this.root, ".local", "backups");
|
|
1500
|
+
const walkBackups = async (dir) => {
|
|
1501
|
+
let entries = [];
|
|
1502
|
+
try {
|
|
1503
|
+
entries = await readdir(dir);
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
for (const name of entries) {
|
|
1509
|
+
const full = join(dir, name);
|
|
1510
|
+
let st;
|
|
1511
|
+
try {
|
|
1512
|
+
st = await stat(full);
|
|
1513
|
+
}
|
|
1514
|
+
catch {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
if (st.isDirectory()) {
|
|
1518
|
+
await walkBackups(full);
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if (!name.endsWith(".json.bak"))
|
|
1522
|
+
continue;
|
|
1523
|
+
const rel = full.slice(backupsRoot.length).replace(/^\//, "");
|
|
1524
|
+
const mainPath = join(this.root, rel.slice(0, -".bak".length));
|
|
1525
|
+
try {
|
|
1526
|
+
await stat(mainPath);
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
try {
|
|
1530
|
+
await unlink(full);
|
|
1531
|
+
removed += 1;
|
|
1532
|
+
}
|
|
1533
|
+
catch { /* ignore */ }
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
await walkBackups(backupsRoot);
|
|
1365
1538
|
}
|
|
1366
1539
|
catch { /* best-effort */ }
|
|
1367
1540
|
return { removed };
|
|
@@ -1592,18 +1765,13 @@ export class PlanStore {
|
|
|
1592
1765
|
return "done";
|
|
1593
1766
|
const hasDone = meaningful.some((s) => s === "done");
|
|
1594
1767
|
const hasActive = meaningful.some((s) => s === "in-progress");
|
|
1595
|
-
const hasPaused = meaningful.some((s) => s === "paused");
|
|
1596
1768
|
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1597
1769
|
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1598
1770
|
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1599
1771
|
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1600
1772
|
if (hasActive)
|
|
1601
1773
|
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)
|
|
1774
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1607
1775
|
return "deferred";
|
|
1608
1776
|
// Partial completion with remaining planned/blocked/waiting work still means
|
|
1609
1777
|
// the phase has genuinely started and is not terminal yet.
|
|
@@ -1616,8 +1784,6 @@ export class PlanStore {
|
|
|
1616
1784
|
return "waiting";
|
|
1617
1785
|
if (hasDeferred)
|
|
1618
1786
|
return "deferred";
|
|
1619
|
-
if (hasPaused)
|
|
1620
|
-
return "paused";
|
|
1621
1787
|
return "planned";
|
|
1622
1788
|
}
|
|
1623
1789
|
deriveFeatureStatus(featureId, phases) {
|
|
@@ -1633,17 +1799,13 @@ export class PlanStore {
|
|
|
1633
1799
|
return "done";
|
|
1634
1800
|
const hasDone = meaningful.some((s) => s === "done");
|
|
1635
1801
|
const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
|
|
1636
|
-
const hasPaused = meaningful.some((s) => s === "paused");
|
|
1637
1802
|
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1638
1803
|
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1639
1804
|
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1640
1805
|
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1641
1806
|
if (hasActive)
|
|
1642
1807
|
return "in-progress";
|
|
1643
|
-
|
|
1644
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasDeferred && hasPaused)
|
|
1645
|
-
return "paused";
|
|
1646
|
-
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasPaused && hasDeferred)
|
|
1808
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1647
1809
|
return "deferred";
|
|
1648
1810
|
if (hasDone)
|
|
1649
1811
|
return "in-progress";
|
|
@@ -1654,8 +1816,6 @@ export class PlanStore {
|
|
|
1654
1816
|
return "waiting";
|
|
1655
1817
|
if (hasDeferred)
|
|
1656
1818
|
return "deferred";
|
|
1657
|
-
if (hasPaused)
|
|
1658
|
-
return "paused";
|
|
1659
1819
|
return "planned";
|
|
1660
1820
|
}
|
|
1661
1821
|
async syncStatuses() {
|
|
@@ -1740,24 +1900,23 @@ export class PlanStore {
|
|
|
1740
1900
|
}
|
|
1741
1901
|
/** Persist an explicitly approved work deviation without coupling it to a harness. */
|
|
1742
1902
|
async addWorkDeviation(deviation) {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
}));
|
|
1903
|
+
const deviations = [...(await this.loadWorkDeviations()), deviation];
|
|
1904
|
+
await this.saveWorkDeviations(deviations);
|
|
1905
|
+
return this.loadProject();
|
|
1747
1906
|
}
|
|
1748
1907
|
/** Advance a deviation while retaining its complete audit and return stack. */
|
|
1749
1908
|
async setWorkDeviationState(id, state, timestamp = nowISO()) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1909
|
+
const deviations = await this.loadWorkDeviations();
|
|
1910
|
+
const next = deviations.map((deviation) => deviation.id !== id ? deviation : {
|
|
1911
|
+
...deviation,
|
|
1912
|
+
state,
|
|
1913
|
+
activatedAt: state === "active" ? timestamp : deviation.activatedAt,
|
|
1914
|
+
resumeRequiredAt: state === "resume-required" ? timestamp : deviation.resumeRequiredAt,
|
|
1915
|
+
resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
|
|
1916
|
+
resumedAt: state === "resumed" ? timestamp : deviation.resumedAt,
|
|
1917
|
+
});
|
|
1918
|
+
await this.saveWorkDeviations(next);
|
|
1919
|
+
return this.loadProject();
|
|
1761
1920
|
}
|
|
1762
1921
|
async updateFeatures(updater) {
|
|
1763
1922
|
const updated = await this.withFeaturesLock(async () => {
|
|
@@ -1784,7 +1943,9 @@ export class PlanStore {
|
|
|
1784
1943
|
return updated;
|
|
1785
1944
|
}
|
|
1786
1945
|
async saveProject(project) {
|
|
1787
|
-
|
|
1946
|
+
// Runtime workDeviations live in .local/deviations.json (T299); never
|
|
1947
|
+
// persist them here so shared project.json stays stable across worktrees.
|
|
1948
|
+
const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
|
|
1788
1949
|
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
1789
1950
|
await this.touchTimestamp();
|
|
1790
1951
|
await this.maybeAutoSync();
|
|
@@ -1896,11 +2057,12 @@ export class PlanStore {
|
|
|
1896
2057
|
? { ...timestamped, featureId: resolvedFeatureId }
|
|
1897
2058
|
: timestamped;
|
|
1898
2059
|
return this.normalizePhaseDocument(normalizedInput).phase;
|
|
1899
|
-
});
|
|
2060
|
+
}, this.root);
|
|
2061
|
+
await this.pruneObsoleteWorkDeviations();
|
|
1900
2062
|
await this.maybeAutoSync();
|
|
1901
2063
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
1902
2064
|
}
|
|
1903
|
-
/**
|
|
2065
|
+
/** Save a durable resume checkpoint without introducing a separate canonical task status. */
|
|
1904
2066
|
async pauseTask(phaseId, taskId, input) {
|
|
1905
2067
|
const snapshot = TaskPauseSnapshotSchema.parse(input);
|
|
1906
2068
|
let paused;
|
|
@@ -1909,7 +2071,7 @@ export class PlanStore {
|
|
|
1909
2071
|
if (!task)
|
|
1910
2072
|
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
1911
2073
|
if (task.status !== "in-progress") {
|
|
1912
|
-
throw new PlanStoreError(`Task ${taskId} cannot be
|
|
2074
|
+
throw new PlanStoreError(`Task ${taskId} cannot be checkpointed from ${task.status}; only in-progress work can capture a pause snapshot.`);
|
|
1913
2075
|
}
|
|
1914
2076
|
const description = [
|
|
1915
2077
|
`Reason: ${snapshot.reason}`,
|
|
@@ -1919,15 +2081,15 @@ export class PlanStore {
|
|
|
1919
2081
|
].join("\n");
|
|
1920
2082
|
paused = {
|
|
1921
2083
|
...task,
|
|
1922
|
-
status: "
|
|
2084
|
+
status: "planned",
|
|
1923
2085
|
pauseSnapshot: snapshot,
|
|
1924
2086
|
pauseHistory: [...task.pauseHistory, snapshot],
|
|
1925
2087
|
statusLog: [...task.statusLog, {
|
|
1926
2088
|
id: createStatusLogEntryId(),
|
|
1927
2089
|
date: snapshot.pausedAt,
|
|
1928
2090
|
fromStatus: "in-progress",
|
|
1929
|
-
toStatus: "
|
|
1930
|
-
title: "in-progress →
|
|
2091
|
+
toStatus: "planned",
|
|
2092
|
+
title: "in-progress → planned (checkpoint saved)",
|
|
1931
2093
|
description,
|
|
1932
2094
|
}],
|
|
1933
2095
|
updatedAt: snapshot.pausedAt,
|
|
@@ -1937,15 +2099,18 @@ export class PlanStore {
|
|
|
1937
2099
|
});
|
|
1938
2100
|
return paused;
|
|
1939
2101
|
}
|
|
1940
|
-
/** Resume a
|
|
2102
|
+
/** Resume a checkpointed task without resetting its original startedAt. */
|
|
1941
2103
|
async resumeTask(phaseId, taskId, timestamp = nowISO()) {
|
|
1942
2104
|
let resumed;
|
|
1943
2105
|
await this.updatePhase(phaseId, (phase) => {
|
|
1944
2106
|
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
1945
2107
|
if (!task)
|
|
1946
2108
|
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
1947
|
-
if (task.
|
|
1948
|
-
throw new PlanStoreError(`Task ${taskId}
|
|
2109
|
+
if (!task.pauseSnapshot) {
|
|
2110
|
+
throw new PlanStoreError(`Task ${taskId} has no saved checkpoint to resume.`);
|
|
2111
|
+
}
|
|
2112
|
+
if (task.status === "done" || task.status === "canceled" || task.status === "rejected") {
|
|
2113
|
+
throw new PlanStoreError(`Task ${taskId} cannot be resumed from ${task.status}.`);
|
|
1949
2114
|
}
|
|
1950
2115
|
const snapshot = task.pauseSnapshot;
|
|
1951
2116
|
resumed = {
|
|
@@ -1956,12 +2121,10 @@ export class PlanStore {
|
|
|
1956
2121
|
statusLog: [...task.statusLog, {
|
|
1957
2122
|
id: createStatusLogEntryId(),
|
|
1958
2123
|
date: timestamp,
|
|
1959
|
-
fromStatus:
|
|
2124
|
+
fromStatus: task.status,
|
|
1960
2125
|
toStatus: "in-progress",
|
|
1961
|
-
title:
|
|
1962
|
-
description: snapshot
|
|
1963
|
-
? `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`
|
|
1964
|
-
: "Paused task resumed.",
|
|
2126
|
+
title: `${task.status} → in-progress (resume checkpoint)`,
|
|
2127
|
+
description: `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`,
|
|
1965
2128
|
}],
|
|
1966
2129
|
updatedAt: timestamp,
|
|
1967
2130
|
};
|
|
@@ -2186,16 +2349,16 @@ export class PlanStore {
|
|
|
2186
2349
|
await this.unlinkPhaseFiles(phaseId);
|
|
2187
2350
|
await this.touchTimestamp();
|
|
2188
2351
|
}
|
|
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. */
|
|
2352
|
+
/** Remove a phase file and BOTH of its previous-version backups: the legacy
|
|
2353
|
+
* inline `phases/<id>.json.bak` (kept for backward compat) and the relocated
|
|
2354
|
+
* `.local/backups/phases/<id>.json.bak` (written once updatePhase passes root,
|
|
2355
|
+
* per P043). Leaving either behind would RESURRECT the deleted phase on the
|
|
2356
|
+
* next read, since readJson falls back to both locations. */
|
|
2196
2357
|
async unlinkPhaseFiles(phaseId) {
|
|
2197
|
-
|
|
2198
|
-
await unlink(
|
|
2358
|
+
const phaseFile = this.phasePath(phaseId);
|
|
2359
|
+
await unlink(phaseFile).catch(() => { });
|
|
2360
|
+
await unlink(`${phaseFile}.bak`).catch(() => { });
|
|
2361
|
+
await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
|
|
2199
2362
|
}
|
|
2200
2363
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
2201
2364
|
/** Load the full workspace (manifest + phases + project + requirements + features) */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical planner extension rules — the agent-behavior contract that applies
|
|
3
|
+
* to EVERY project using the Agent Plan extension (Pi, MCP / Claude Code / Codex,
|
|
4
|
+
* future harnesses). These are STATIC: no timestamps, no dynamic content, so the
|
|
5
|
+
* same text seeds every .planner/ and never diverges across worktrees or
|
|
6
|
+
* branches (no conflict from date/timestamp changes).
|
|
7
|
+
*
|
|
8
|
+
* AGENTS.md governs ONLY the development of the agent-plan extension and must
|
|
9
|
+
* not duplicate these rules.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PLANNER_EXTENSION_RULES: string[];
|
|
12
|
+
export interface ExtensionRulesFile {
|
|
13
|
+
extensionRules: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Load the effective extension rules for a planner root. Returns the project's
|
|
17
|
+
* own .planner/rules.json (static, user-overridable) when present and non-empty,
|
|
18
|
+
* otherwise the canonical code set. Never returns timestamps or dynamic data.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadExtensionRules(plannerRoot: string): Promise<string[]>;
|
|
21
|
+
//# sourceMappingURL=planner-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"planner-rules.d.ts","sourceRoot":"","sources":["../src/planner-rules.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,EA2B3C,CAAC;AAEF,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAW/E"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Canonical planner extension rules — the agent-behavior contract that applies
|
|
5
|
+
* to EVERY project using the Agent Plan extension (Pi, MCP / Claude Code / Codex,
|
|
6
|
+
* future harnesses). These are STATIC: no timestamps, no dynamic content, so the
|
|
7
|
+
* same text seeds every .planner/ and never diverges across worktrees or
|
|
8
|
+
* branches (no conflict from date/timestamp changes).
|
|
9
|
+
*
|
|
10
|
+
* AGENTS.md governs ONLY the development of the agent-plan extension and must
|
|
11
|
+
* not duplicate these rules.
|
|
12
|
+
*/
|
|
13
|
+
export const PLANNER_EXTENSION_RULES = [
|
|
14
|
+
// §1 — source of truth
|
|
15
|
+
"Keep the planner as the single operational source of truth while working: read the relevant planner state before starting; update it when an activity starts, changes state, blocks, or concludes; and record next steps, blockers, and decisions in the relevant planner entities. Never leave work only in the conversation.",
|
|
16
|
+
// §2 — task lifecycle
|
|
17
|
+
"Respect the task lifecycle strictly. Always call task_start before touching code, and task_complete when a deliverable is done. Sync state changes (start/complete/block) to the planner at the exact moment they happen — never batch updates at session end. If the extension reports 'no active task', immediately start the correct task. A task marked in-progress means you are actually working on it; if you stop, close or block it with a motivation in statusLog. Derived feature/phase status is computed from tasks, not stored in JSON.",
|
|
18
|
+
// §3 — markdown not source of truth
|
|
19
|
+
"Do not treat markdown as the source of truth for the plan. The plan's primary source is structured data in .planner/; markdown is a generated, human/agent-readable view.",
|
|
20
|
+
// §5 — plan location
|
|
21
|
+
"The plan lives in .planner/ within the target project. Whether .planner/ is git-tracked is at the project's discretion.",
|
|
22
|
+
// §6 — discuss per phase
|
|
23
|
+
"Discuss the plan per phase: clarify objective, scope, non-scope, dependencies, risks, and outcomes before working a phase; detail implementation when the phase is actually worked, not up front.",
|
|
24
|
+
// §7 — naming
|
|
25
|
+
"Naming: phases and tasks use global project-wide numbering (P001, T001, …) with a slug derived from the title. Numbers are assigned once at creation from a monotonic global counter and never reused (deletes leave gaps).",
|
|
26
|
+
// §8 — status changes & motivation
|
|
27
|
+
"Every task status change is recorded in an incremental statusLog. Motivation is mandatory for blocked/canceled/rejected/deferred/waiting and for returning to planned from a non-planned status; not required for done or for normal in-progress-from-planned. Use task_update (not task_start/task_complete) for non-lifecycle status changes, with an exhaustive motivation.",
|
|
28
|
+
// §10 — references
|
|
29
|
+
"Reference entities with human, unique, composite IDs — Feature 'F001 - Name', Phase 'P001(F001) - Title', Task 'T003 - Number'. Short forms P003/T007 and the 5-char global shortId (e.g. UUXD1) are also valid. Never reference raw UUIDs. To locate an entity, use the compact list tools (feature_list/phase_list/task_list), not by reading .planner/*.json files.",
|
|
30
|
+
// §12 — handoff
|
|
31
|
+
"Handoff is per-phase (phase.handoff), not a file. Write it only on explicit user request; on resume, read it then clear it before starting work. A pending handoff never blocks task_start. Do not leave a handoff stale after processing it.",
|
|
32
|
+
// §12 — operational hygiene
|
|
33
|
+
"Operational hygiene: start the task (task_start) before thinking about implementation; complete it (task_complete) as part of delivering the deliverable, not after; motivate every block so a third party can understand the impediment.",
|
|
34
|
+
// Avvio del planner
|
|
35
|
+
"The planner and Web UI never start automatically. Do not start the Web UI or show its URL unless the user runs load/recap/web-status. The Web UI URL appears only in the recap after load, or on explicit web status.",
|
|
36
|
+
// Regola dettagli
|
|
37
|
+
"Write relevant points (decisions, constraints, current state, file:line refs, edge cases) into the task/phase/feature description or notes as soon as they emerge. Before starting, resuming, or switching to a task, read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order; read linked requirements explicitly when present. Cite entities with composite IDs, not bare UUIDs.",
|
|
38
|
+
// Expected operational behavior
|
|
39
|
+
"When you begin work, task_start and task_switch enforce the required ordered full reads. Read any relevant phase handoff as additional context, then update the planner before and after significant changes. If you change an architectural decision, document it explicitly.",
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Load the effective extension rules for a planner root. Returns the project's
|
|
43
|
+
* own .planner/rules.json (static, user-overridable) when present and non-empty,
|
|
44
|
+
* otherwise the canonical code set. Never returns timestamps or dynamic data.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadExtensionRules(plannerRoot) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = await readFile(join(plannerRoot, "rules.json"), "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (Array.isArray(parsed.extensionRules) && parsed.extensionRules.length > 0) {
|
|
51
|
+
return parsed.extensionRules.filter((r) => typeof r === "string" && r.length > 0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Missing or malformed rules.json → fall back to the canonical code set.
|
|
56
|
+
}
|
|
57
|
+
return PLANNER_EXTENSION_RULES;
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session, ordered context-read enforcement for agent lifecycle operations.
|
|
3
|
+
*
|
|
4
|
+
* An agent must read the exact task, then its parent phase, then its parent
|
|
5
|
+
* feature with full=true before it can start, resume, or switch to that task.
|
|
6
|
+
* Compact list/identity reads never count. The state is process-local and is
|
|
7
|
+
* cleared on pause, switch, and session startup by the harness adapters.
|
|
8
|
+
*
|
|
9
|
+
* Linked requirements remain a separate explicit-read gate. They are recorded
|
|
10
|
+
* only through the requirement list tool and never by an entity read.
|
|
11
|
+
*/
|
|
12
|
+
export type ContextReadEligibility = {
|
|
13
|
+
eligible: boolean;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
/** Record a full feature read. */
|
|
17
|
+
export declare function markFeatureRead(featureId: string): void;
|
|
18
|
+
/** Record a full phase read. The parent feature is intentionally not implied. */
|
|
19
|
+
export declare function markPhaseRead(phaseId: string, _featureId?: string): void;
|
|
20
|
+
/** Record a full task read. Its parent phase and feature are intentionally not implied. */
|
|
21
|
+
export declare function markTaskRead(taskId: string, _phaseId?: string, _featureId?: string): void;
|
|
22
|
+
/** Record that a requirement was explicitly read via the requirement list tool. */
|
|
23
|
+
export declare function markRequirementRead(requirementId: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Verify the required task(full) → phase(full) → feature(full) order for one
|
|
26
|
+
* exact task lineage. Orphan phases do not require a feature read.
|
|
27
|
+
*/
|
|
28
|
+
export declare function contextReadEligibility(taskId: string, phaseId: string, featureId?: string): ContextReadEligibility;
|
|
29
|
+
/**
|
|
30
|
+
* Legacy parent-read check retained for callers that only need to know whether
|
|
31
|
+
* both parent entities have been read. Lifecycle gates must use
|
|
32
|
+
* contextReadEligibility so the task and ordering cannot be bypassed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasReadParents(featureId: string | undefined, phaseId: string): boolean;
|
|
35
|
+
/** Whether every linked requirement has been explicitly read. */
|
|
36
|
+
export declare function hasReadRequirements(requirementIds: string[]): boolean;
|
|
37
|
+
/** Clear all read state so later lifecycle work requires fresh context. */
|
|
38
|
+
export declare function invalidateReads(): void;
|
|
39
|
+
/** Compatibility advisory for non-lifecycle callers. */
|
|
40
|
+
export declare function parentReadAdvisory(featureId: string | undefined, phaseId: string): string;
|
|
41
|
+
/** Advisory text for the separate linked-requirements gate. */
|
|
42
|
+
export declare function requirementReadAdvisory(requirementIds: string[]): string;
|
|
43
|
+
/** Snapshot for diagnostics and tests. */
|
|
44
|
+
export declare function readTrackingSnapshot(): {
|
|
45
|
+
features: string[];
|
|
46
|
+
phases: string[];
|
|
47
|
+
requirements: string[];
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=read-tracking.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-tracking.d.ts","sourceRoot":"","sources":["../src/read-tracking.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAeF,kCAAkC;AAClC,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAExE;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAEzF;AAED,mFAAmF;AACnF,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAE/D;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,sBAAsB,CAmBlH;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAItF;AAED,iEAAiE;AACjE,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAErE;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,IAAI,IAAI,CAQtC;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAGzF;AAED,+DAA+D;AAC/D,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,MAAM,CAKxE;AAED,0CAA0C;AAC1C,wBAAgB,oBAAoB,IAAI;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,EAAE,CAAA;CAAE,CAMvG"}
|