@agent-plan/core 0.2.18 → 0.2.19-next.1
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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/naming.d.ts +22 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +36 -1
- package/dist/plan-store.d.ts +70 -5
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +406 -107
- package/dist/recap.d.ts +32 -0
- package/dist/recap.d.ts.map +1 -0
- package/dist/recap.js +105 -0
- package/dist/refs.d.ts +24 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +42 -0
- package/dist/schema.d.ts +117 -27
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +13 -2
- package/package.json +3 -2
package/dist/plan-store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { access, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { CodebaseProfileSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema,
|
|
4
|
-
import { createFeatureId, createPhaseId, createRequirementId, createTaskId, isLegacyPhaseId } from "./naming.js";
|
|
3
|
+
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
|
|
4
|
+
import { createFeatureId, createPhaseId, createRequirementId, createShortId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
|
|
5
5
|
function nowISO() {
|
|
6
6
|
return new Date().toISOString();
|
|
7
7
|
}
|
|
@@ -108,6 +108,12 @@ async function atomicUpdateJson(path, schema, updater) {
|
|
|
108
108
|
return parsed;
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
|
+
/** Extract the first meaningful line of a handoff: skip blank lines, strip a
|
|
112
|
+
* leading markdown header (#), trim, and truncate to ~80 chars. */
|
|
113
|
+
function handoffFirstLine(text) {
|
|
114
|
+
const line = text.trim().split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
115
|
+
return line.replace(/^#+\s*/, "").trim().slice(0, 80);
|
|
116
|
+
}
|
|
111
117
|
export async function migrateToUuids(store) {
|
|
112
118
|
// Run as a batch so internal saveFeatures/savePhase calls do not
|
|
113
119
|
// re-trigger syncStatuses (O(N^2) on large planners). Idempotent: if there
|
|
@@ -187,7 +193,6 @@ async function readJson(path, schema) {
|
|
|
187
193
|
throw new PlanStoreError(`read failed: ${path}`, cause);
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
|
-
// ─── PlanStore ──────────────────────────────────────────────────────────
|
|
191
196
|
export class PlanStore {
|
|
192
197
|
root;
|
|
193
198
|
autoSync = false;
|
|
@@ -224,16 +229,15 @@ export class PlanStore {
|
|
|
224
229
|
async runBatchForMigration(fn) {
|
|
225
230
|
return this.runAsBatch(fn);
|
|
226
231
|
}
|
|
232
|
+
/** Public batch wrapper: suspend autoSync (status rollup) for a sequence of
|
|
233
|
+
* writes. Use for priority-only reorders so they don't recompute phase/feature
|
|
234
|
+
* status (a reorder must not flip a partially-done feature to in-progress). */
|
|
235
|
+
async runBatch(fn) {
|
|
236
|
+
return this.runAsBatch(fn);
|
|
237
|
+
}
|
|
227
238
|
async maybeAutoSync() {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
this.syncGuard = true;
|
|
232
|
-
await this.syncStatuses();
|
|
233
|
-
}
|
|
234
|
-
finally {
|
|
235
|
-
this.syncGuard = false;
|
|
236
|
-
}
|
|
239
|
+
// No-op: status is derived on read, so there is nothing to sync after a
|
|
240
|
+
// save. Kept so existing save* call sites compile unchanged.
|
|
237
241
|
}
|
|
238
242
|
normalizeTasks(tasks) {
|
|
239
243
|
let changed = false;
|
|
@@ -335,7 +339,7 @@ export class PlanStore {
|
|
|
335
339
|
}
|
|
336
340
|
async ensureStructureOrdering() {
|
|
337
341
|
return this.runAsBatch(async () => {
|
|
338
|
-
const featuresDoc = await
|
|
342
|
+
const featuresDoc = await this.loadFeatures();
|
|
339
343
|
const phases = await this.loadAllPhases();
|
|
340
344
|
const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
|
|
341
345
|
if (!normalized.changed)
|
|
@@ -360,6 +364,35 @@ export class PlanStore {
|
|
|
360
364
|
featuresPath() {
|
|
361
365
|
return join(this.root, "features.json");
|
|
362
366
|
}
|
|
367
|
+
featuresDir() {
|
|
368
|
+
return join(this.root, "features");
|
|
369
|
+
}
|
|
370
|
+
featurePath(featureId) {
|
|
371
|
+
return join(this.featuresDir(), `${featureId}.json`);
|
|
372
|
+
}
|
|
373
|
+
withFeaturesLock(fn) {
|
|
374
|
+
// Sentinel-keyed mutex (the features dir path) serializing all feature
|
|
375
|
+
// mutations so read-modify-write via updateFeatures is race-free and the
|
|
376
|
+
// one-time legacy→per-file migration never interleaves with a writer.
|
|
377
|
+
return withWriteLock(this.featuresDir(), fn);
|
|
378
|
+
}
|
|
379
|
+
/** Idempotent one-time migration: if a legacy features.json exists, split it
|
|
380
|
+
* into features/<id>.json (one per feature) and remove the legacy file.
|
|
381
|
+
* Must be called under withFeaturesLock. Crash-safe: re-run overwrites. */
|
|
382
|
+
async migrateLegacy() {
|
|
383
|
+
let legacy;
|
|
384
|
+
try {
|
|
385
|
+
legacy = await readJson(this.featuresPath(), FeaturesDocumentSchema);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return; // no legacy features.json (already migrated or fresh project)
|
|
389
|
+
}
|
|
390
|
+
await mkdir(this.featuresDir(), { recursive: true });
|
|
391
|
+
for (const feat of legacy.features) {
|
|
392
|
+
await atomicWriteJson(this.featurePath(feat.id), feat);
|
|
393
|
+
}
|
|
394
|
+
await unlink(this.featuresPath()).catch(() => { });
|
|
395
|
+
}
|
|
363
396
|
phasesDir() {
|
|
364
397
|
return join(this.root, "phases");
|
|
365
398
|
}
|
|
@@ -388,6 +421,7 @@ export class PlanStore {
|
|
|
388
421
|
}
|
|
389
422
|
await mkdir(this.root, { recursive: true });
|
|
390
423
|
await mkdir(this.phasesDir(), { recursive: true });
|
|
424
|
+
await mkdir(this.featuresDir(), { recursive: true });
|
|
391
425
|
await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
|
|
392
426
|
await mkdir(join(this.root, "schema"), { recursive: true });
|
|
393
427
|
await mkdir(join(this.root, "adapters"), { recursive: true });
|
|
@@ -474,18 +508,58 @@ export class PlanStore {
|
|
|
474
508
|
return readJson(this.projectPath(), ProjectSchema);
|
|
475
509
|
}
|
|
476
510
|
async loadPhase(phaseId) {
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
511
|
+
const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
|
|
512
|
+
const normalized = this.normalizePhaseDocument(raw).phase;
|
|
513
|
+
return { ...normalized, status: this.derivePhaseStatus(normalized.tasks) };
|
|
514
|
+
}
|
|
515
|
+
/** Read raw feature files WITHOUT the derived `status` field. Used
|
|
516
|
+
* internally so loadFeatures/loadAll can derive status from phases without
|
|
517
|
+
* double-loading. */
|
|
518
|
+
async loadRawFeatures() {
|
|
519
|
+
let jsonFiles = [];
|
|
481
520
|
try {
|
|
482
|
-
const
|
|
483
|
-
|
|
521
|
+
const all = await readdir(this.featuresDir());
|
|
522
|
+
jsonFiles = all.filter((f) => f.endsWith(".json"));
|
|
484
523
|
}
|
|
485
524
|
catch {
|
|
486
|
-
|
|
525
|
+
// features/ absent → fall through to legacy single-file layout
|
|
526
|
+
}
|
|
527
|
+
if (jsonFiles.length > 0) {
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const f of jsonFiles) {
|
|
530
|
+
const id = f.replace(/\.json$/, "");
|
|
531
|
+
try {
|
|
532
|
+
out.push(await readJson(this.featurePath(id), FeatureSchema));
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
// Skip an invalid feature file rather than failing the whole load.
|
|
536
|
+
console.warn(`[plan-store] skipping invalid feature file ${f}:`, err);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Deterministic order: sort by the persisted `number` (creation order)
|
|
540
|
+
// so callers that renumber by index (normalizeFeaturesDocument) and
|
|
541
|
+
// callers that keep persisted numbers (loadAll) agree, regardless of the
|
|
542
|
+
// filesystem readdir order. Tiebreak by id for full determinism.
|
|
543
|
+
out.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
// Legacy: single features.json (pre-migration read; migration writes on first write op).
|
|
547
|
+
try {
|
|
548
|
+
const legacy = await readJson(this.featuresPath(), FeaturesDocumentSchema);
|
|
549
|
+
const legacyFeatures = legacy.features;
|
|
550
|
+
legacyFeatures.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
|
|
551
|
+
return legacyFeatures;
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return [];
|
|
487
555
|
}
|
|
488
556
|
}
|
|
557
|
+
async loadFeatures() {
|
|
558
|
+
const raws = await this.loadRawFeatures();
|
|
559
|
+
const phases = await this.loadAllPhases();
|
|
560
|
+
const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
561
|
+
return this.normalizeFeaturesDocument({ features }).doc;
|
|
562
|
+
}
|
|
489
563
|
async loadCodebaseProfile() {
|
|
490
564
|
try {
|
|
491
565
|
return await readJson(this.codebasePath(), CodebaseProfileSchema);
|
|
@@ -671,14 +745,15 @@ export class PlanStore {
|
|
|
671
745
|
});
|
|
672
746
|
}
|
|
673
747
|
async loadAll() {
|
|
674
|
-
const [manifest, project,
|
|
748
|
+
const [manifest, project, requirements, phases] = await Promise.all([
|
|
675
749
|
this.loadManifest(),
|
|
676
750
|
this.loadProject(),
|
|
677
|
-
this.loadFeatures(),
|
|
678
751
|
this.loadRequirements(),
|
|
679
752
|
this.loadAllPhases(),
|
|
680
753
|
]);
|
|
681
|
-
|
|
754
|
+
const rawFeatures = await this.loadRawFeatures();
|
|
755
|
+
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
756
|
+
return { manifest, project, requirements, phases, features: { features } };
|
|
682
757
|
}
|
|
683
758
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
684
759
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -795,13 +870,156 @@ export class PlanStore {
|
|
|
795
870
|
catch { /* best-effort */ }
|
|
796
871
|
return { removed };
|
|
797
872
|
}
|
|
873
|
+
/** All non-empty shortIds currently assigned in the project (features + phases + tasks).
|
|
874
|
+
* Read-only; used by createShortId collision guard during entity creation. */
|
|
875
|
+
async assignedShortIds() {
|
|
876
|
+
const features = await this.loadFeatures();
|
|
877
|
+
const phases = await this.loadAllPhases();
|
|
878
|
+
const ids = new Set();
|
|
879
|
+
for (const f of features.features)
|
|
880
|
+
if (f.shortId)
|
|
881
|
+
ids.add(f.shortId);
|
|
882
|
+
for (const p of phases) {
|
|
883
|
+
if (p.shortId)
|
|
884
|
+
ids.add(p.shortId);
|
|
885
|
+
for (const t of p.tasks)
|
|
886
|
+
if (t.shortId)
|
|
887
|
+
ids.add(t.shortId);
|
|
888
|
+
}
|
|
889
|
+
return ids;
|
|
890
|
+
}
|
|
891
|
+
/** Next priority (>=1) for a new entity within its scope.
|
|
892
|
+
* - feature: max priority among features + 1
|
|
893
|
+
* - phase: max priority among phases of parentId (featureId) + 1
|
|
894
|
+
* - task: max priority among tasks of parentId (phaseId) + 1 */
|
|
895
|
+
async nextPriority(kind, parentId) {
|
|
896
|
+
if (kind === "feature") {
|
|
897
|
+
const features = await this.loadFeatures();
|
|
898
|
+
const max = features.features.reduce((m, f) => Math.max(m, f.priority ?? 0), 0);
|
|
899
|
+
return max + 1;
|
|
900
|
+
}
|
|
901
|
+
if (kind === "phase") {
|
|
902
|
+
const phases = await this.loadAllPhases();
|
|
903
|
+
const siblings = phases.filter((p) => p.featureId === parentId);
|
|
904
|
+
const max = siblings.reduce((m, p) => Math.max(m, p.priority ?? 0), 0);
|
|
905
|
+
return max + 1;
|
|
906
|
+
}
|
|
907
|
+
// task
|
|
908
|
+
const phase = parentId ? await this.loadPhase(parentId).catch(() => undefined) : undefined;
|
|
909
|
+
const tasks = phase?.tasks ?? [];
|
|
910
|
+
const max = tasks.reduce((m, t) => Math.max(m, t.priority ?? 0), 0);
|
|
911
|
+
return max + 1;
|
|
912
|
+
}
|
|
913
|
+
/** Idempotent backfill of shortId (globally-unique 5-char Crockford) and priority
|
|
914
|
+
* (per-scope display order). Assigns missing shortIds and priorities; never overwrites
|
|
915
|
+
* existing non-empty shortIds or non-zero priorities. Safe to run at startup. */
|
|
916
|
+
async ensureShortIdsAndPriority() {
|
|
917
|
+
return this.runAsBatch(async () => {
|
|
918
|
+
const featuresDoc = await this.loadFeatures();
|
|
919
|
+
const phases = await this.loadAllPhases();
|
|
920
|
+
const existing = new Set();
|
|
921
|
+
for (const f of featuresDoc.features)
|
|
922
|
+
if (f.shortId)
|
|
923
|
+
existing.add(f.shortId);
|
|
924
|
+
for (const p of phases) {
|
|
925
|
+
if (p.shortId)
|
|
926
|
+
existing.add(p.shortId);
|
|
927
|
+
for (const t of p.tasks)
|
|
928
|
+
if (t.shortId)
|
|
929
|
+
existing.add(t.shortId);
|
|
930
|
+
}
|
|
931
|
+
let shortIdsAssigned = 0;
|
|
932
|
+
let prioritiesAssigned = 0;
|
|
933
|
+
let featuresDirty = false;
|
|
934
|
+
const assignPriority = (current, index) => {
|
|
935
|
+
if (current === 0) {
|
|
936
|
+
prioritiesAssigned += 1;
|
|
937
|
+
return index + 1;
|
|
938
|
+
}
|
|
939
|
+
return current;
|
|
940
|
+
};
|
|
941
|
+
// Features: shortId + priority (project scope)
|
|
942
|
+
const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
943
|
+
sortedFeatures.forEach((f, index) => {
|
|
944
|
+
if (!f.shortId) {
|
|
945
|
+
f.shortId = createShortId(existing);
|
|
946
|
+
existing.add(f.shortId);
|
|
947
|
+
shortIdsAssigned += 1;
|
|
948
|
+
featuresDirty = true;
|
|
949
|
+
}
|
|
950
|
+
const nextP = assignPriority(f.priority ?? 0, index);
|
|
951
|
+
if (nextP !== f.priority) {
|
|
952
|
+
f.priority = nextP;
|
|
953
|
+
featuresDirty = true;
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
if (featuresDirty) {
|
|
957
|
+
featuresDoc.features.sort((a, b) => a.priority - b.priority || a.number - b.number);
|
|
958
|
+
await this.saveFeatures(featuresDoc);
|
|
959
|
+
}
|
|
960
|
+
// Phases + tasks
|
|
961
|
+
for (const phase of phases) {
|
|
962
|
+
let phaseDirty = false;
|
|
963
|
+
if (!phase.shortId) {
|
|
964
|
+
phase.shortId = createShortId(existing);
|
|
965
|
+
existing.add(phase.shortId);
|
|
966
|
+
shortIdsAssigned += 1;
|
|
967
|
+
phaseDirty = true;
|
|
968
|
+
}
|
|
969
|
+
const phaseIndex = phase.number - 1; // stable pre-migration order
|
|
970
|
+
const nextPP = assignPriority(phase.priority ?? 0, phaseIndex < 0 ? 0 : phaseIndex);
|
|
971
|
+
if (nextPP !== phase.priority) {
|
|
972
|
+
phase.priority = nextPP;
|
|
973
|
+
phaseDirty = true;
|
|
974
|
+
}
|
|
975
|
+
const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
976
|
+
sortedTasks.forEach((t, index) => {
|
|
977
|
+
if (!t.shortId) {
|
|
978
|
+
t.shortId = createShortId(existing);
|
|
979
|
+
existing.add(t.shortId);
|
|
980
|
+
shortIdsAssigned += 1;
|
|
981
|
+
phaseDirty = true;
|
|
982
|
+
}
|
|
983
|
+
const nextTP = assignPriority(t.priority ?? 0, index);
|
|
984
|
+
if (nextTP !== t.priority) {
|
|
985
|
+
t.priority = nextTP;
|
|
986
|
+
phaseDirty = true;
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
if (phaseDirty) {
|
|
990
|
+
phase.tasks.sort((a, b) => a.priority - b.priority || a.number - b.number);
|
|
991
|
+
phase.taskIds = phase.tasks.map((t) => t.id);
|
|
992
|
+
phase.updatedAt = nowISO();
|
|
993
|
+
await this.savePhase(phase);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Duplicate shortId report (across all entities)
|
|
997
|
+
const allShortIds = [];
|
|
998
|
+
for (const f of featuresDoc.features)
|
|
999
|
+
if (f.shortId)
|
|
1000
|
+
allShortIds.push(f.shortId);
|
|
1001
|
+
for (const ph of phases) {
|
|
1002
|
+
if (ph.shortId)
|
|
1003
|
+
allShortIds.push(ph.shortId);
|
|
1004
|
+
for (const t of ph.tasks)
|
|
1005
|
+
if (t.shortId)
|
|
1006
|
+
allShortIds.push(t.shortId);
|
|
1007
|
+
}
|
|
1008
|
+
const counts = new Map();
|
|
1009
|
+
for (const id of allShortIds)
|
|
1010
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
1011
|
+
const duplicateShortIds = [...counts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
1012
|
+
return { shortIdsAssigned, prioritiesAssigned, duplicateShortIds };
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
798
1015
|
/** Repair dangling references and report integrity. One-shot maintenance op. */
|
|
799
1016
|
async repair() {
|
|
800
1017
|
return this.runAsBatch(async () => {
|
|
801
1018
|
const migrated = await this.migratePhaseIds();
|
|
1019
|
+
const backfill = await this.ensureShortIdsAndPriority();
|
|
802
1020
|
const integrity = await this.validateIntegrity();
|
|
803
1021
|
await this.writeGenerated();
|
|
804
|
-
return { migrated, integrity };
|
|
1022
|
+
return { migrated, backfill, integrity };
|
|
805
1023
|
});
|
|
806
1024
|
}
|
|
807
1025
|
/** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
|
|
@@ -821,104 +1039,95 @@ export class PlanStore {
|
|
|
821
1039
|
danglingPhaseIds.push(`${feature.id} -> ${ref}`);
|
|
822
1040
|
}
|
|
823
1041
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
|
|
1042
|
+
// Duplicate shortIds across all entities
|
|
1043
|
+
const allShortIds = [];
|
|
1044
|
+
for (const f of features.features)
|
|
1045
|
+
if (f.shortId)
|
|
1046
|
+
allShortIds.push(f.shortId);
|
|
1047
|
+
for (const ph of phases) {
|
|
1048
|
+
if (ph.shortId)
|
|
1049
|
+
allShortIds.push(ph.shortId);
|
|
1050
|
+
for (const t of ph.tasks)
|
|
1051
|
+
if (t.shortId)
|
|
1052
|
+
allShortIds.push(t.shortId);
|
|
1053
|
+
}
|
|
1054
|
+
const sidCounts = new Map();
|
|
1055
|
+
for (const id of allShortIds)
|
|
1056
|
+
sidCounts.set(id, (sidCounts.get(id) ?? 0) + 1);
|
|
1057
|
+
const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
1058
|
+
return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
|
|
1059
|
+
}
|
|
1060
|
+
derivePhaseStatus(tasks) {
|
|
1061
|
+
if (tasks.length === 0)
|
|
1062
|
+
return "draft";
|
|
1063
|
+
const taskStatuses = tasks.map((task) => task.status);
|
|
1064
|
+
// Ignore rejected/canceled tasks (void) when deriving progress.
|
|
1065
|
+
const meaningful = taskStatuses.filter((s) => s !== "rejected" && s !== "canceled");
|
|
1066
|
+
if (meaningful.length === 0)
|
|
838
1067
|
return "rejected";
|
|
839
|
-
if (
|
|
840
|
-
return "
|
|
841
|
-
|
|
1068
|
+
if (meaningful.every((s) => s === "done"))
|
|
1069
|
+
return "done";
|
|
1070
|
+
// Lifecycle truth: any progress (active work OR partial completion) ⇒
|
|
1071
|
+
// in-progress, until fully done. This is what prevents a single
|
|
1072
|
+
// blocked/waiting/deferred task from poisoning the parent when there is
|
|
1073
|
+
// substantial done or in-progress work (the long-standing rollup bug).
|
|
1074
|
+
if (meaningful.some((s) => s === "in-progress") || meaningful.some((s) => s === "done"))
|
|
842
1075
|
return "in-progress";
|
|
843
|
-
|
|
1076
|
+
// No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
|
|
1077
|
+
if (meaningful.some((s) => s === "blocked"))
|
|
1078
|
+
return "blocked";
|
|
1079
|
+
if (meaningful.some((s) => s === "waiting"))
|
|
844
1080
|
return "waiting";
|
|
845
|
-
if (
|
|
1081
|
+
if (meaningful.some((s) => s === "deferred"))
|
|
846
1082
|
return "deferred";
|
|
847
|
-
if (anyPlanned)
|
|
848
|
-
return "planned";
|
|
849
|
-
if (anyDone)
|
|
850
|
-
return "done";
|
|
851
1083
|
return "planned";
|
|
852
1084
|
}
|
|
853
|
-
deriveFeatureStatus(featureId,
|
|
1085
|
+
deriveFeatureStatus(featureId, phases) {
|
|
854
1086
|
const featurePhases = phases.filter((phase) => phase.featureId === featureId);
|
|
855
1087
|
if (featurePhases.length === 0)
|
|
856
|
-
return
|
|
1088
|
+
return "planned";
|
|
857
1089
|
const phaseStatuses = featurePhases.map((phase) => phase.status);
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
const anyWaiting = phaseStatuses.some((status) => status === "waiting");
|
|
862
|
-
const anyDeferred = phaseStatuses.some((status) => status === "deferred");
|
|
863
|
-
const anyPlannedLike = phaseStatuses.some((status) => status === "draft" || status === "planned");
|
|
864
|
-
const anyDone = phaseStatuses.every((status) => status === "done");
|
|
865
|
-
if (allRejectedOrCanceled)
|
|
1090
|
+
// Ignore rejected/canceled phases when deriving progress.
|
|
1091
|
+
const meaningful = phaseStatuses.filter((s) => s !== "rejected" && s !== "canceled");
|
|
1092
|
+
if (meaningful.length === 0)
|
|
866
1093
|
return "rejected";
|
|
867
|
-
if (
|
|
868
|
-
return "
|
|
869
|
-
|
|
1094
|
+
if (meaningful.every((s) => s === "done"))
|
|
1095
|
+
return "done";
|
|
1096
|
+
// Any progress (an active phase, or a partially-complete done phase) ⇒
|
|
1097
|
+
// in-progress. Prevents a single stalled phase from poisoning the feature
|
|
1098
|
+
// when other phases have done/in-progress work.
|
|
1099
|
+
if (meaningful.some((s) => s === "discovery" || s === "in-progress") || meaningful.some((s) => s === "done"))
|
|
870
1100
|
return "in-progress";
|
|
871
|
-
|
|
1101
|
+
// No progress at all ⇒ surface the stall / not-started state.
|
|
1102
|
+
if (meaningful.some((s) => s === "blocked"))
|
|
1103
|
+
return "blocked";
|
|
1104
|
+
if (meaningful.some((s) => s === "waiting"))
|
|
872
1105
|
return "waiting";
|
|
873
|
-
if (
|
|
1106
|
+
if (meaningful.some((s) => s === "deferred"))
|
|
874
1107
|
return "deferred";
|
|
875
|
-
if (anyPlannedLike)
|
|
876
|
-
return "planned";
|
|
877
|
-
if (anyDone)
|
|
878
|
-
return "done";
|
|
879
1108
|
return "planned";
|
|
880
1109
|
}
|
|
881
1110
|
async syncStatuses() {
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
// 2. Update Feature statuses based on phases
|
|
893
|
-
for (const feature of features.features) {
|
|
894
|
-
feature.status = this.deriveFeatureStatus(feature.id, feature.status, phases);
|
|
895
|
-
}
|
|
896
|
-
// 3. Save everything
|
|
897
|
-
await this.saveFeatures(features);
|
|
898
|
-
for (const phase of phases) {
|
|
899
|
-
await this.savePhase(phase);
|
|
900
|
-
}
|
|
901
|
-
// 4. Refresh resume focus so a subentrating agent sees current state
|
|
902
|
-
await this.refreshResume();
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
/** Optimized rollup: syncs only the affected phase and its parent feature.
|
|
906
|
-
* Drastically reduces write operations and 'busy' window for task updates. */
|
|
1111
|
+
// No-op: phase/feature status is now DERIVED at read time (never persisted),
|
|
1112
|
+
// so there is nothing to sync. Kept for backward compatibility with callers
|
|
1113
|
+
// (serve.ts, adapters) that invoke it after mutations.
|
|
1114
|
+
return [];
|
|
1115
|
+
}
|
|
1116
|
+
/** Auto-clear a phase's handoff when its DERIVED status is done. Status itself
|
|
1117
|
+
* is no longer persisted (derived on read), so the only remaining side effect
|
|
1118
|
+
* of a task→done transition is clearing a stale handoff on a completed phase.
|
|
1119
|
+
* Returns the composite ref of the phase if its handoff was cleared, else null. */
|
|
907
1120
|
async syncTaskStatusRollup(phaseId) {
|
|
908
1121
|
const phase = await this.loadPhase(phaseId);
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
const feature =
|
|
914
|
-
|
|
915
|
-
// To derive feature status, we still need the statuses of all its phases
|
|
916
|
-
const allPhases = await this.loadAllPhases();
|
|
917
|
-
feature.status = this.deriveFeatureStatus(feature.id, feature.status, allPhases);
|
|
918
|
-
await this.saveFeatures(featuresDoc);
|
|
919
|
-
}
|
|
1122
|
+
let cleared = null;
|
|
1123
|
+
if (phase.status === "done" && phase.handoff !== "") {
|
|
1124
|
+
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffUpdatedAt: nowISO() }));
|
|
1125
|
+
const features = await this.loadFeatures();
|
|
1126
|
+
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1127
|
+
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
920
1128
|
}
|
|
921
1129
|
await this.refreshResume();
|
|
1130
|
+
return cleared;
|
|
922
1131
|
}
|
|
923
1132
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
924
1133
|
async updateProject(updater) {
|
|
@@ -927,7 +1136,13 @@ export class PlanStore {
|
|
|
927
1136
|
return updated;
|
|
928
1137
|
}
|
|
929
1138
|
async updateFeatures(updater) {
|
|
930
|
-
const updated = await
|
|
1139
|
+
const updated = await this.withFeaturesLock(async () => {
|
|
1140
|
+
await this.migrateLegacy();
|
|
1141
|
+
const current = await this.loadFeatures();
|
|
1142
|
+
const upd = this.normalizeFeaturesDocument(updater(current)).doc;
|
|
1143
|
+
await this.saveFeaturesRaw(upd);
|
|
1144
|
+
return upd;
|
|
1145
|
+
});
|
|
931
1146
|
await this.maybeAutoSync();
|
|
932
1147
|
return updated;
|
|
933
1148
|
}
|
|
@@ -943,8 +1158,45 @@ export class PlanStore {
|
|
|
943
1158
|
await this.maybeAutoSync();
|
|
944
1159
|
}
|
|
945
1160
|
async saveFeatures(features) {
|
|
1161
|
+
await this.withFeaturesLock(async () => {
|
|
1162
|
+
await this.migrateLegacy();
|
|
1163
|
+
await this.saveFeaturesRaw(features);
|
|
1164
|
+
});
|
|
1165
|
+
await this.touchManifest();
|
|
1166
|
+
await this.maybeAutoSync();
|
|
1167
|
+
}
|
|
1168
|
+
/** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
|
|
1169
|
+
async saveFeaturesRaw(features) {
|
|
946
1170
|
const parsed = FeaturesDocumentSchema.parse(this.normalizeFeaturesDocument(features).doc);
|
|
947
|
-
await
|
|
1171
|
+
await mkdir(this.featuresDir(), { recursive: true });
|
|
1172
|
+
const wantIds = new Set(parsed.features.map((f) => f.id));
|
|
1173
|
+
for (const feat of parsed.features) {
|
|
1174
|
+
await atomicWriteJson(this.featurePath(feat.id), feat);
|
|
1175
|
+
}
|
|
1176
|
+
// Orphan reconcile: remove feature files no longer in the document.
|
|
1177
|
+
try {
|
|
1178
|
+
const files = await readdir(this.featuresDir());
|
|
1179
|
+
for (const f of files) {
|
|
1180
|
+
if (!f.endsWith(".json"))
|
|
1181
|
+
continue;
|
|
1182
|
+
const id = f.replace(/\.json$/, "");
|
|
1183
|
+
if (!wantIds.has(id)) {
|
|
1184
|
+
await unlink(this.featurePath(id)).catch(() => { });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
// dir absent — nothing to reconcile
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/** Granular single-feature write (per-file lock; parallel-safe across features). */
|
|
1193
|
+
async saveFeature(feature) {
|
|
1194
|
+
await this.withFeaturesLock(async () => {
|
|
1195
|
+
await this.migrateLegacy();
|
|
1196
|
+
await mkdir(this.featuresDir(), { recursive: true });
|
|
1197
|
+
const parsed = FeatureSchema.parse(feature);
|
|
1198
|
+
await atomicWriteJson(this.featurePath(parsed.id), parsed);
|
|
1199
|
+
});
|
|
948
1200
|
await this.touchManifest();
|
|
949
1201
|
await this.maybeAutoSync();
|
|
950
1202
|
}
|
|
@@ -964,9 +1216,56 @@ export class PlanStore {
|
|
|
964
1216
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
965
1217
|
* don't lose tasks (last-write-wins race condition). */
|
|
966
1218
|
async updatePhase(phaseId, updater) {
|
|
967
|
-
|
|
1219
|
+
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
1220
|
+
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
1221
|
+
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
1222
|
+
// not persisted); the return value is re-derived for the caller.
|
|
1223
|
+
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
1224
|
+
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
1225
|
+
const next = updater(current);
|
|
1226
|
+
return this.normalizePhaseDocument(next).phase;
|
|
1227
|
+
});
|
|
968
1228
|
await this.maybeAutoSync();
|
|
969
|
-
return
|
|
1229
|
+
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
1230
|
+
}
|
|
1231
|
+
// ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
|
|
1232
|
+
/** Get the handoff text for a phase ("" if none). Throws if phase missing. */
|
|
1233
|
+
async getPhaseHandoff(phaseId) {
|
|
1234
|
+
return (await this.loadPhase(phaseId)).handoff;
|
|
1235
|
+
}
|
|
1236
|
+
/** Set the handoff text for a phase + stamp handoffUpdatedAt. Atomic per-file
|
|
1237
|
+
* update via updatePhase (so the web UI refreshes via maybeAutoSync). */
|
|
1238
|
+
async setPhaseHandoff(phaseId, text) {
|
|
1239
|
+
const now = new Date().toISOString();
|
|
1240
|
+
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
|
|
1241
|
+
}
|
|
1242
|
+
/** Clear the handoff text for a phase (handoff=""). handoffUpdatedAt is left
|
|
1243
|
+
* unchanged as an audit trail (when a handoff last existed). */
|
|
1244
|
+
async clearPhaseHandoff(phaseId) {
|
|
1245
|
+
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
|
|
1246
|
+
}
|
|
1247
|
+
/** List all phases that have a non-empty handoff, newest first, with a
|
|
1248
|
+
* human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
|
|
1249
|
+
async listHandoffs() {
|
|
1250
|
+
const phases = await this.loadAllPhases();
|
|
1251
|
+
const features = await this.loadFeatures();
|
|
1252
|
+
const featureNumber = new Map();
|
|
1253
|
+
for (const f of features.features)
|
|
1254
|
+
featureNumber.set(f.id, f.number);
|
|
1255
|
+
const out = [];
|
|
1256
|
+
for (const p of phases) {
|
|
1257
|
+
if (!p.handoff)
|
|
1258
|
+
continue;
|
|
1259
|
+
const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
|
|
1260
|
+
out.push({
|
|
1261
|
+
phaseId: p.id,
|
|
1262
|
+
compositeRef: formatPhaseRef(p.number, fnum),
|
|
1263
|
+
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
1264
|
+
firstLine: handoffFirstLine(p.handoff),
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1268
|
+
return out;
|
|
970
1269
|
}
|
|
971
1270
|
async deletePhase(phaseId) {
|
|
972
1271
|
try {
|