@agent-plan/core 0.2.24 → 0.2.26

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.
Files changed (50) hide show
  1. package/dist/display-status.d.ts +3 -3
  2. package/dist/display-status.d.ts.map +1 -1
  3. package/dist/display-status.js +5 -4
  4. package/dist/handoff-context.d.ts +91 -1
  5. package/dist/handoff-context.d.ts.map +1 -1
  6. package/dist/handoff-context.js +134 -2
  7. package/dist/index.d.ts +5 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -0
  10. package/dist/naming.d.ts +3 -0
  11. package/dist/naming.d.ts.map +1 -1
  12. package/dist/naming.js +7 -0
  13. package/dist/payload-fallback.d.ts +38 -0
  14. package/dist/payload-fallback.d.ts.map +1 -0
  15. package/dist/payload-fallback.js +73 -0
  16. package/dist/plan-store.d.ts +65 -8
  17. package/dist/plan-store.d.ts.map +1 -1
  18. package/dist/plan-store.js +391 -39
  19. package/dist/planner-rules.d.ts +3 -11
  20. package/dist/planner-rules.d.ts.map +1 -1
  21. package/dist/planner-rules.js +23 -6
  22. package/dist/planner-skill.d.ts +24 -0
  23. package/dist/planner-skill.d.ts.map +1 -0
  24. package/dist/planner-skill.js +113 -0
  25. package/dist/project-context-migration.d.ts +47 -0
  26. package/dist/project-context-migration.d.ts.map +1 -0
  27. package/dist/project-context-migration.js +168 -0
  28. package/dist/read-tracking.d.ts +27 -8
  29. package/dist/read-tracking.d.ts.map +1 -1
  30. package/dist/read-tracking.js +79 -34
  31. package/dist/recap.d.ts.map +1 -1
  32. package/dist/recap.js +2 -6
  33. package/dist/refs.d.ts +6 -1
  34. package/dist/refs.d.ts.map +1 -1
  35. package/dist/refs.js +25 -0
  36. package/dist/renderer.d.ts.map +1 -1
  37. package/dist/renderer.js +24 -1
  38. package/dist/requirement-macro-tasks.d.ts +18 -0
  39. package/dist/requirement-macro-tasks.d.ts.map +1 -0
  40. package/dist/requirement-macro-tasks.js +55 -0
  41. package/dist/schema.d.ts +1088 -273
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +65 -0
  44. package/dist/task-selection.js +2 -2
  45. package/dist/task-start-outcome.d.ts +1 -1
  46. package/dist/task-start-outcome.d.ts.map +1 -1
  47. package/dist/task-start-outcome.js +1 -0
  48. package/package.json +3 -1
  49. package/planner-skill.md +210 -0
  50. package/skills/grill-me/SKILL.md +10 -0
@@ -1,5 +1,5 @@
1
1
  import { access, copyFile, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join, resolve } from "node:path";
2
+ import { basename, dirname, join, resolve, sep } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z, ZodError } from "zod";
5
5
  /** Canonical `.planner/.gitignore` content (P042 spec): ignore the `.local/`
@@ -14,11 +14,13 @@ 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, WorkDeviationSchema, } from "./schema.js";
18
- import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
17
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
18
+ import { createFeatureId, createIdeaId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatFeatureRef, formatIdeaRef, formatPhaseRef, formatThreeDigitNumber, isLegacyPhaseId } from "./naming.js";
19
19
  import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
20
20
  import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
21
- import { applyHandoffContextSync, auditPhaseHandoff, } from "./handoff-context.js";
21
+ import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
22
+ import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
23
+ import { applyHandoffContextSync, auditPhaseHandoff, handoffContentHash, HandoffContractError, } from "./handoff-context.js";
22
24
  function nowISO() {
23
25
  return new Date().toISOString();
24
26
  }
@@ -89,7 +91,7 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
89
91
  /** Allocation registry is deliberately outside the versioned plan. Git worktrees
90
92
  * share their common git dir, so reservations are serialized across branches
91
93
  * without rewriting project.json or unrelated planner entities. */
92
- const AllocationKindSchema = z.enum(["feature", "phase", "task"]);
94
+ const AllocationKindSchema = z.enum(["feature", "phase", "task", "idea"]);
93
95
  const AllocationRegistrySchema = z.object({
94
96
  version: z.literal(1),
95
97
  projectId: z.string().min(1),
@@ -584,7 +586,7 @@ export class PlanStore {
584
586
  // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
585
587
  // Do NOT renumber here — renumbering would break references after deletions.
586
588
  const normalized = tasks.map((task) => {
587
- const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
589
+ const descriptionUpdatedAt = (task.description || task.descriptionRef) && !task.descriptionUpdatedAt
588
590
  ? task.createdAt
589
591
  : task.descriptionUpdatedAt;
590
592
  const normalizedStatus = task.status === "paused" ? "planned" : task.status;
@@ -623,17 +625,56 @@ export class PlanStore {
623
625
  /** Stamp description edits independently from generic entity mutations.
624
626
  * Legacy entities cannot reveal their historical description-edit time, so
625
627
  * their creation time is the earliest truthful fallback. */
626
- stampDescriptionUpdatedAt(entity, previousDescription, timestamp) {
627
- if (!entity.description)
628
+ stampDescriptionUpdatedAt(entity, previousDescription, previousDescriptionRef, timestamp) {
629
+ if (!entity.description && !entity.descriptionRef)
628
630
  return { ...entity, descriptionUpdatedAt: "" };
629
- if (previousDescription === undefined || previousDescription !== entity.description) {
631
+ if (previousDescription === undefined
632
+ || previousDescription !== entity.description
633
+ || previousDescriptionRef !== entity.descriptionRef) {
630
634
  return { ...entity, descriptionUpdatedAt: timestamp };
631
635
  }
632
636
  return entity.descriptionUpdatedAt ? entity : { ...entity, descriptionUpdatedAt: entity.createdAt };
633
637
  }
638
+ stampProjectGuidelinesUpdatedAt(project, previousContent, timestamp) {
639
+ const nextContent = project.projectGuidelines.content.trim();
640
+ const nextGuidelines = {
641
+ content: nextContent,
642
+ updatedAt: project.projectGuidelines.updatedAt,
643
+ sessionInfo: project.projectGuidelines.sessionInfo,
644
+ };
645
+ if (!nextContent) {
646
+ return {
647
+ ...project,
648
+ projectGuidelines: {
649
+ ...nextGuidelines,
650
+ updatedAt: "",
651
+ sessionInfo: [],
652
+ },
653
+ };
654
+ }
655
+ if (previousContent === undefined || previousContent !== nextContent) {
656
+ return {
657
+ ...project,
658
+ projectGuidelines: {
659
+ ...nextGuidelines,
660
+ updatedAt: timestamp,
661
+ },
662
+ };
663
+ }
664
+ if (nextGuidelines.updatedAt) {
665
+ return { ...project, projectGuidelines: nextGuidelines };
666
+ }
667
+ return {
668
+ ...project,
669
+ projectGuidelines: {
670
+ ...nextGuidelines,
671
+ updatedAt: timestamp,
672
+ },
673
+ };
674
+ }
634
675
  normalizeFeaturesDocument(doc) {
635
676
  // Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
636
- const features = doc.features.map((feature) => feature.description && !feature.descriptionUpdatedAt
677
+ const features = doc.features.map((feature) => (feature.description || feature.descriptionRef) && !feature.descriptionUpdatedAt
637
678
  ? { ...feature, descriptionUpdatedAt: feature.createdAt }
638
679
  : feature);
639
680
  return { doc: { ...doc, features }, changed: features.some((feature, index) => feature !== doc.features[index]) };
@@ -642,7 +683,7 @@ export class PlanStore {
642
683
  const { tasks, changed } = this.normalizeTasks(phase.tasks);
643
684
  const nextTaskIds = tasks.map((task) => task.id);
644
685
  const taskIdsChanged = nextTaskIds.length !== phase.taskIds.length || nextTaskIds.some((id, index) => id !== phase.taskIds[index]);
645
- const descriptionUpdatedAt = phase.description && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
686
+ const descriptionUpdatedAt = (phase.description || phase.descriptionRef) && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
646
687
  const descriptionTimestampChanged = descriptionUpdatedAt !== phase.descriptionUpdatedAt;
647
688
  return {
648
689
  phase: {
@@ -780,6 +821,9 @@ export class PlanStore {
780
821
  requirementsPath() {
781
822
  return join(this.root, "requirements.json");
782
823
  }
824
+ ideasPath() {
825
+ return join(this.root, "ideas.json");
826
+ }
783
827
  featuresPath() {
784
828
  return join(this.root, "features.json");
785
829
  }
@@ -946,6 +990,11 @@ export class PlanStore {
946
990
  name: projectName,
947
991
  goal: "",
948
992
  description: "",
993
+ projectGuidelines: {
994
+ content: "",
995
+ updatedAt: "",
996
+ sessionInfo: [],
997
+ },
949
998
  webPort: 0,
950
999
  scope: [],
951
1000
  outOfScope: [],
@@ -967,6 +1016,7 @@ export class PlanStore {
967
1016
  workDeviations: [],
968
1017
  });
969
1018
  await this.saveRequirements({ requirements: [] });
1019
+ await this.saveIdeas({ nextIdeaNumber: 1, ideas: [] });
970
1020
  await this.saveFeatures({ features: [] });
971
1021
  await this.saveResume({
972
1022
  updatedAt: nowISO(),
@@ -985,6 +1035,8 @@ export class PlanStore {
985
1035
  // never causes git conflicts. The canonical source is plan-core; this file
986
1036
  // is the per-project copy / override point, loaded at planner startup.
987
1037
  await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
1038
+ await this.syncPlannerSkill();
1039
+ await this.syncGrillMeSkill();
988
1040
  // Write a README stub
989
1041
  const readme = [
990
1042
  "# Project Plan",
@@ -994,8 +1046,11 @@ export class PlanStore {
994
1046
  "## Structure",
995
1047
  "",
996
1048
  "- `manifest.json` — metadata",
1049
+ "- `SKILL.md` — managed cross-harness planner operating guide",
1050
+ "- `skills/grill-me/SKILL.md` — managed idea-discussion interview skill",
997
1051
  "- `project.json` — scope, rules, stack, tools",
998
1052
  "- `requirements.json` — requirements and macro-tasks",
1053
+ "- `ideas.json` — top-level Ideas Inbox (excluded from work status derivation)",
999
1054
  "- `phases/` — one JSON file per phase",
1000
1055
  "- `generated/` — auto-generated markdown views (under `.local/`)",
1001
1056
  "- `schema/plan.schema.json` — JSON Schema for tooling",
@@ -1018,6 +1073,22 @@ export class PlanStore {
1018
1073
  async extensionRules() {
1019
1074
  return loadExtensionRules(this.root);
1020
1075
  }
1076
+ /**
1077
+ * Create or safely refresh the project-local planner usage skill. Explicit
1078
+ * planner-load surfaces call this so unmodified managed copies follow the
1079
+ * installed Agent Plan version while project customizations are preserved.
1080
+ */
1081
+ async syncPlannerSkill() {
1082
+ return syncProjectPlannerSkill(this.root);
1083
+ }
1084
+ /** Safely create or refresh the project-local grill-me skill for Ideas. */
1085
+ async syncGrillMeSkill() {
1086
+ return syncProjectGrillMeSkill(this.root);
1087
+ }
1088
+ /** Load grill-me instructions only when an Ideas discussion requests them. */
1089
+ async ideaDiscussionSkill() {
1090
+ return loadProjectGrillMeSkill(this.root);
1091
+ }
1021
1092
  /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
1022
1093
  * canonical transient/derived patterns). Projects initialized before the
1023
1094
  * `.local/` move either have no `.planner/.gitignore` or one with stale
@@ -1077,7 +1148,7 @@ export class PlanStore {
1077
1148
  * Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
1078
1149
  * the same cross-process lock used for atomic writes. The registry reserves
1079
1150
  * numbers/short IDs before an entity file is written, so parallel branches
1080
- * cannot allocate the same F/P/T or shortId. Existing entities are never
1151
+ * cannot allocate the same F/P/T/I or shortId. Existing entities are never
1081
1152
  * rewritten; cross-clone coordination requires a shared allocator service.
1082
1153
  */
1083
1154
  async allocateEntityIdentity(kind, entityId) {
@@ -1098,19 +1169,30 @@ export class PlanStore {
1098
1169
  return { number: prior.number, shortId: prior.shortId };
1099
1170
  const phases = await this.loadAllPhases();
1100
1171
  const features = (await this.loadFeatures()).features;
1172
+ const ideasDocument = await this.loadIdeas();
1173
+ const ideas = ideasDocument.ideas;
1101
1174
  const canonical = kind === "feature"
1102
1175
  ? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
1103
1176
  : kind === "phase"
1104
1177
  ? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
1105
- : phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })));
1178
+ : kind === "task"
1179
+ ? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
1180
+ : ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
1106
1181
  const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
1107
- const counter = kind === "feature" ? project.nextFeatureNumber : kind === "phase" ? project.nextPhaseNumber : project.nextTaskNumber;
1182
+ const counter = kind === "feature"
1183
+ ? project.nextFeatureNumber
1184
+ : kind === "phase"
1185
+ ? project.nextPhaseNumber
1186
+ : kind === "task"
1187
+ ? project.nextTaskNumber
1188
+ : ideasDocument.nextIdeaNumber;
1108
1189
  let number = Math.max(1, counter);
1109
1190
  while (usedNumbers.has(number))
1110
1191
  number += 1;
1111
1192
  const allShortIds = new Set([
1112
1193
  ...features.map((feature) => feature.shortId),
1113
1194
  ...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
1195
+ ...ideas.map((idea) => idea.shortId),
1114
1196
  ...registry.allocations.map((entry) => entry.shortId),
1115
1197
  ].filter(Boolean));
1116
1198
  const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
@@ -1124,6 +1206,7 @@ export class PlanStore {
1124
1206
  async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
1125
1207
  async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
1126
1208
  async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
1209
+ async allocIdeaNumber() { return this.allocateLegacyNumber("idea"); }
1127
1210
  async allocateLegacyNumber(kind) {
1128
1211
  const id = `legacy-${kind}-${randomUUID()}`;
1129
1212
  return (await this.allocateEntityIdentity(kind, id)).number;
@@ -1316,6 +1399,18 @@ export class PlanStore {
1316
1399
  return { requirements: [] };
1317
1400
  }
1318
1401
  }
1402
+ async loadIdeas() {
1403
+ try {
1404
+ const document = await readJson(this.ideasPath(), IdeasDocumentSchema);
1405
+ return {
1406
+ ...document,
1407
+ ideas: [...document.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
1408
+ };
1409
+ }
1410
+ catch {
1411
+ return { nextIdeaNumber: 1, ideas: [] };
1412
+ }
1413
+ }
1319
1414
  async linkedRequirementsForPhase(phaseId) {
1320
1415
  const requirements = await this.loadRequirements();
1321
1416
  return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
@@ -1390,16 +1485,17 @@ export class PlanStore {
1390
1485
  return deriveParentDisplay(childStatuses);
1391
1486
  }
1392
1487
  async loadAll() {
1393
- const [manifest, project, requirements, phases] = await Promise.all([
1488
+ const [manifest, project, requirements, ideas, phases] = await Promise.all([
1394
1489
  this.loadManifest(),
1395
1490
  this.loadProject(),
1396
1491
  this.loadRequirements(),
1492
+ this.loadIdeas(),
1397
1493
  this.loadAllPhases(),
1398
1494
  ]);
1399
1495
  const rawFeatures = await this.loadRawFeatures();
1400
1496
  const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
1401
1497
  const normalized = this.normalizeStructureSnapshot({ features }, phases);
1402
- return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
1498
+ return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
1403
1499
  }
1404
1500
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
1405
1501
  * dangling feature.phaseIds references. Idempotent. */
@@ -1907,10 +2003,50 @@ export class PlanStore {
1907
2003
  }
1908
2004
  // ── Savers ───────────────────────────────────────────────────────────
1909
2005
  async updateProject(updater) {
1910
- const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
2006
+ const timestamp = nowISO();
2007
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
2008
+ const previousGuidelines = current.projectGuidelines.content;
2009
+ const candidate = updater(current);
2010
+ return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
2011
+ }, this.root);
1911
2012
  await this.maybeAutoSync();
1912
2013
  return updated;
1913
2014
  }
2015
+ /** Preview the explicit, lossless migration from legacy project rule and
2016
+ * decision fields into Project Guidelines and structured accepted decisions. */
2017
+ async previewLegacyProjectContextMigration() {
2018
+ return previewLegacyProjectContextMigration(await readJson(this.projectPath(), ProjectSchema));
2019
+ }
2020
+ /** Prepare an explicit planner session before recap/context delivery.
2021
+ * Ordinary entity reads remain non-mutating. */
2022
+ async preparePlannerSession() {
2023
+ return plannerSessionPreparationResult(await this.migrateLegacyProjectContext());
2024
+ }
2025
+ /** Apply and verify the legacy project-context migration. Callers must invoke
2026
+ * this only from an explicit preparation or compatibility-recovery flow. */
2027
+ async migrateLegacyProjectContext() {
2028
+ const original = await readJson(this.projectPath(), ProjectSchema);
2029
+ const acceptedAt = nowISO();
2030
+ const expected = applyLegacyProjectContextMigration(original, acceptedAt);
2031
+ if (!expected.applied)
2032
+ return expected;
2033
+ await this.updateProject(() => expected.project);
2034
+ const persisted = await readJson(this.projectPath(), ProjectSchema);
2035
+ const migratedDecisionIds = new Set(expected.preview.acceptedDecisionAdditions.map((decision) => decision.id));
2036
+ const persistedDecisionIds = new Set(persisted.acceptedDecisions.map((decision) => decision.id));
2037
+ const verified = persisted.projectGuidelines.content === expected.preview.resultingGuidelinesContent
2038
+ && persisted.globalRules.length === 0
2039
+ && persisted.workflowRules.beforePhaseStart.length === 0
2040
+ && persisted.workflowRules.beforeTaskStart.length === 0
2041
+ && persisted.workflowRules.afterPhaseComplete.length === 0
2042
+ && persisted.decisions.length === 0
2043
+ && [...migratedDecisionIds].every((id) => persistedDecisionIds.has(id));
2044
+ if (!verified) {
2045
+ await atomicWriteJson(this.projectPath(), ProjectSchema.parse(original), this.root);
2046
+ throw new PlanStoreError("Legacy project-context migration failed persisted read-back verification; the original project was restored.");
2047
+ }
2048
+ return { applied: true, preview: expected.preview, project: persisted };
2049
+ }
1914
2050
  /** Persist an explicitly approved work deviation without coupling it to a harness. */
1915
2051
  async addWorkDeviation(deviation) {
1916
2052
  const deviations = [...(await this.loadWorkDeviations()), deviation];
@@ -1938,10 +2074,11 @@ export class PlanStore {
1938
2074
  // Updaters commonly mutate `current` in place, so snapshot descriptions
1939
2075
  // before invoking them rather than comparing object references afterward.
1940
2076
  const previousDescriptions = new Map(current.features.map((feature) => [feature.id, feature.description]));
2077
+ const previousDescriptionRefs = new Map(current.features.map((feature) => [feature.id, feature.descriptionRef]));
1941
2078
  const timestamp = nowISO();
1942
2079
  const candidate = updater(current);
1943
2080
  const stamped = {
1944
- features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
2081
+ features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
1945
2082
  };
1946
2083
  const upd = this.normalizeFeaturesDocument(stamped).doc;
1947
2084
  await this.saveFeaturesRaw(upd);
@@ -1955,6 +2092,135 @@ export class PlanStore {
1955
2092
  await this.maybeAutoSync();
1956
2093
  return updated;
1957
2094
  }
2095
+ async ensureIdeasFileForWrite() {
2096
+ try {
2097
+ await access(this.ideasPath());
2098
+ }
2099
+ catch {
2100
+ await atomicWriteJson(this.ideasPath(), IdeasDocumentSchema.parse({ nextIdeaNumber: 1, ideas: [] }), this.root);
2101
+ }
2102
+ }
2103
+ async updateIdeas(updater) {
2104
+ await this.ensureIdeasFileForWrite();
2105
+ const updated = await atomicUpdateJson(this.ideasPath(), IdeasDocumentSchema, (current) => {
2106
+ const candidate = IdeasDocumentSchema.parse(updater(current));
2107
+ const maxNumber = candidate.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
2108
+ return {
2109
+ ...candidate,
2110
+ nextIdeaNumber: Math.max(candidate.nextIdeaNumber, maxNumber + 1),
2111
+ ideas: [...candidate.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
2112
+ };
2113
+ }, this.root);
2114
+ await this.touchTimestamp();
2115
+ await this.maybeAutoSync();
2116
+ return updated;
2117
+ }
2118
+ async createIdea(input, timestamp = nowISO()) {
2119
+ const id = createIdeaId();
2120
+ const { number, shortId } = await this.allocateEntityIdentity("idea", id);
2121
+ const idea = IdeaSchema.parse({
2122
+ id,
2123
+ number,
2124
+ shortId,
2125
+ title: input.title.trim(),
2126
+ description: input.description?.trim() ?? "",
2127
+ promotion: null,
2128
+ createdAt: timestamp,
2129
+ updatedAt: timestamp,
2130
+ });
2131
+ await this.updateIdeas((document) => ({
2132
+ nextIdeaNumber: Math.max(document.nextIdeaNumber, number + 1),
2133
+ ideas: [...document.ideas, idea],
2134
+ }));
2135
+ return idea;
2136
+ }
2137
+ async updateIdea(ideaId, input, timestamp = nowISO()) {
2138
+ let updated;
2139
+ await this.updateIdeas((document) => {
2140
+ const existing = document.ideas.find((idea) => idea.id === ideaId);
2141
+ if (!existing)
2142
+ throw new PlanStoreError(`Idea ${ideaId} not found.`);
2143
+ updated = IdeaSchema.parse({
2144
+ ...existing,
2145
+ ...(input.title !== undefined ? { title: input.title.trim() } : {}),
2146
+ ...(input.description !== undefined ? { description: input.description.trim() } : {}),
2147
+ updatedAt: timestamp,
2148
+ });
2149
+ return {
2150
+ ...document,
2151
+ ideas: document.ideas.map((idea) => idea.id === ideaId ? updated : idea),
2152
+ };
2153
+ });
2154
+ return updated;
2155
+ }
2156
+ async deleteIdea(ideaId) {
2157
+ let deleted = false;
2158
+ await this.updateIdeas((document) => {
2159
+ deleted = document.ideas.some((idea) => idea.id === ideaId);
2160
+ return deleted
2161
+ ? { ...document, ideas: document.ideas.filter((idea) => idea.id !== ideaId) }
2162
+ : document;
2163
+ });
2164
+ return deleted;
2165
+ }
2166
+ async resolveIdeaPromotionTarget(targetType, targetId) {
2167
+ const phases = await this.loadAllPhases();
2168
+ const features = (await this.loadFeatures()).features;
2169
+ if (targetType === "feature") {
2170
+ const feature = features.find((candidate) => candidate.id === targetId);
2171
+ if (!feature)
2172
+ throw new PlanStoreError(`Idea promotion target feature ${targetId} not found.`);
2173
+ return formatFeatureRef(feature.number);
2174
+ }
2175
+ if (targetType === "phase") {
2176
+ const phase = phases.find((candidate) => candidate.id === targetId);
2177
+ if (!phase)
2178
+ throw new PlanStoreError(`Idea promotion target phase ${targetId} not found.`);
2179
+ const feature = features.find((candidate) => candidate.id === phase.featureId);
2180
+ return formatPhaseRef(phase.number, feature?.number);
2181
+ }
2182
+ for (const phase of phases) {
2183
+ const task = phase.tasks.find((candidate) => candidate.id === targetId);
2184
+ if (!task)
2185
+ continue;
2186
+ const feature = features.find((candidate) => candidate.id === phase.featureId);
2187
+ return `${formatPhaseRef(phase.number, feature?.number)}/T${formatThreeDigitNumber(task.number)}`;
2188
+ }
2189
+ throw new PlanStoreError(`Idea promotion target task ${targetId} not found.`);
2190
+ }
2191
+ async promoteIdea(ideaId, input) {
2192
+ const targetRef = await this.resolveIdeaPromotionTarget(input.targetType, input.targetId);
2193
+ const promotedAt = input.promotedAt ?? nowISO();
2194
+ let promoted;
2195
+ await this.updateIdeas((document) => {
2196
+ const existing = document.ideas.find((idea) => idea.id === ideaId);
2197
+ if (!existing)
2198
+ throw new PlanStoreError(`Idea ${ideaId} not found.`);
2199
+ if (existing.promotion) {
2200
+ if (existing.promotion.targetType === input.targetType && existing.promotion.targetId === input.targetId) {
2201
+ promoted = existing;
2202
+ return document;
2203
+ }
2204
+ throw new PlanStoreError(`Idea ${formatIdeaRef(existing.number)} is already promoted to ${existing.promotion.targetRef}.`);
2205
+ }
2206
+ const promotion = {
2207
+ targetType: input.targetType,
2208
+ targetId: input.targetId,
2209
+ targetRef,
2210
+ promotedAt,
2211
+ };
2212
+ promoted = IdeaSchema.parse({
2213
+ ...existing,
2214
+ promotion,
2215
+ updatedAt: promotedAt,
2216
+ });
2217
+ return {
2218
+ ...document,
2219
+ ideas: document.ideas.map((idea) => idea.id === ideaId ? promoted : idea),
2220
+ };
2221
+ });
2222
+ return promoted;
2223
+ }
1958
2224
  /**
1959
2225
  * Persist completion of one full task → phase → feature → requirements read
1960
2226
  * sequence. Session metadata is deliberately the only changed entity field:
@@ -2039,10 +2305,26 @@ export class PlanStore {
2039
2305
  return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
2040
2306
  });
2041
2307
  }
2308
+ async recordProjectGuidelinesRead(input) {
2309
+ const sessionId = input.sessionId.trim();
2310
+ if (!sessionId)
2311
+ throw new PlanStoreError("Project-guidelines attestation requires a non-empty sessionId.");
2312
+ const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
2313
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (project) => {
2314
+ if (!project.projectGuidelines.content.trim())
2315
+ return project;
2316
+ const result = upsertSessionInfo(project.projectGuidelines, sessionId, createdAt);
2317
+ return result.changed ? { ...project, projectGuidelines: result.entity } : project;
2318
+ }, this.root);
2319
+ await this.maybeAutoSync();
2320
+ return updated;
2321
+ }
2042
2322
  async saveProject(project) {
2043
2323
  // Runtime workDeviations live in .local/deviations.json (T299); never
2044
2324
  // persist them here so shared project.json stays stable across worktrees.
2045
- const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
2325
+ const previous = await readJson(this.projectPath(), ProjectSchema).catch(() => null);
2326
+ const stamped = this.stampProjectGuidelinesUpdatedAt(project, previous?.projectGuidelines.content, nowISO());
2327
+ const parsed = ProjectSchema.parse({ ...stamped, workDeviations: [] });
2046
2328
  await atomicWriteJson(this.projectPath(), parsed, this.root);
2047
2329
  await this.touchTimestamp();
2048
2330
  await this.maybeAutoSync();
@@ -2051,9 +2333,10 @@ export class PlanStore {
2051
2333
  await this.withFeaturesLock(async () => {
2052
2334
  await this.migrateLegacy();
2053
2335
  const previousDescriptions = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.description]));
2336
+ const previousDescriptionRefs = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.descriptionRef]));
2054
2337
  const timestamp = nowISO();
2055
2338
  await this.saveFeaturesRaw({
2056
- features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
2339
+ features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
2057
2340
  });
2058
2341
  });
2059
2342
  await this.touchTimestamp();
@@ -2089,7 +2372,7 @@ export class PlanStore {
2089
2372
  await this.migrateLegacy();
2090
2373
  const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
2091
2374
  await mkdir(this.featuresDir(), { recursive: true });
2092
- const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, nowISO()));
2375
+ const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, previous?.descriptionRef, nowISO()));
2093
2376
  await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
2094
2377
  });
2095
2378
  await this.touchTimestamp();
@@ -2100,6 +2383,16 @@ export class PlanStore {
2100
2383
  await atomicWriteJson(this.requirementsPath(), parsed, this.root);
2101
2384
  await this.touchTimestamp();
2102
2385
  }
2386
+ async saveIdeas(ideas) {
2387
+ const parsed = IdeasDocumentSchema.parse(ideas);
2388
+ const maxNumber = parsed.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
2389
+ await atomicWriteJson(this.ideasPath(), {
2390
+ ...parsed,
2391
+ nextIdeaNumber: Math.max(parsed.nextIdeaNumber, maxNumber + 1),
2392
+ ideas: [...parsed.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
2393
+ }, this.root);
2394
+ await this.touchTimestamp();
2395
+ }
2103
2396
  async savePhase(phase) {
2104
2397
  const previous = await this.loadPhase(phase.id).catch(() => null);
2105
2398
  const timestamp = nowISO();
@@ -2119,8 +2412,9 @@ export class PlanStore {
2119
2412
  ? { ...phase, featureId: resolvedFeatureId }
2120
2413
  : phase;
2121
2414
  const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
2122
- const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, timestamp);
2123
- timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
2415
+ const previousTaskDescriptionRefs = new Map((previous?.tasks ?? []).map((task) => [task.id, task.descriptionRef]));
2416
+ const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, previous?.descriptionRef, timestamp);
2417
+ timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
2124
2418
  const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
2125
2419
  await mkdir(this.phasesDir(), { recursive: true });
2126
2420
  await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
@@ -2140,11 +2434,13 @@ export class PlanStore {
2140
2434
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
2141
2435
  // The updater may mutate `current`, therefore snapshot descriptions first.
2142
2436
  const previousDescription = current.description;
2437
+ const previousDescriptionRef = current.descriptionRef;
2143
2438
  const previousTaskDescriptions = new Map(current.tasks.map((task) => [task.id, task.description]));
2439
+ const previousTaskDescriptionRefs = new Map(current.tasks.map((task) => [task.id, task.descriptionRef]));
2144
2440
  const timestamp = nowISO();
2145
2441
  const next = updater(current);
2146
- const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, timestamp);
2147
- timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
2442
+ const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, previousDescriptionRef, timestamp);
2443
+ timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
2148
2444
  const resolvedFeatureId = resolveStoredFeatureId(features, timestamped.featureId);
2149
2445
  // Referential integrity: reject orphan featureId.
2150
2446
  if (timestamped.featureId && timestamped.featureId.trim() && !resolvedFeatureId) {
@@ -2235,6 +2531,36 @@ export class PlanStore {
2235
2531
  async getPhaseHandoff(phaseId) {
2236
2532
  return (await this.loadPhase(phaseId)).handoff;
2237
2533
  }
2534
+ async validateHandoffSupportingDocuments(documents) {
2535
+ const docsRoot = resolve(this.root, "docs");
2536
+ const verified = [];
2537
+ const seen = new Set();
2538
+ for (const document of documents) {
2539
+ const normalizedPath = document.path.trim().replace(/\\/g, "/");
2540
+ if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
2541
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
2542
+ }
2543
+ if (seen.has(normalizedPath)) {
2544
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
2545
+ }
2546
+ seen.add(normalizedPath);
2547
+ const target = resolve(this.root, normalizedPath.slice(".planner/".length));
2548
+ if (!target.startsWith(`${docsRoot}${sep}`)) {
2549
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
2550
+ }
2551
+ const content = await readFile(target, "utf8").catch(() => null);
2552
+ if (content === null || !content.trim()) {
2553
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
2554
+ }
2555
+ verified.push({
2556
+ path: normalizedPath,
2557
+ description: document.description.trim(),
2558
+ contentHash: handoffContentHash(content),
2559
+ contentLength: content.length,
2560
+ });
2561
+ }
2562
+ return verified;
2563
+ }
2238
2564
  /** Audit one exact phase before preparing a handoff refresh. */
2239
2565
  async preparePhaseHandoff(phaseId) {
2240
2566
  const phase = await this.loadPhase(phaseId);
@@ -2259,25 +2585,46 @@ export class PlanStore {
2259
2585
  if (featureIndex < 0)
2260
2586
  throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
2261
2587
  const timestamp = nowISO();
2262
- const applied = applyHandoffContextSync(originalPhase, originalFeatures.features[featureIndex], input, timestamp);
2263
- const nextFeatures = structuredClone(originalFeatures);
2264
- nextFeatures.features[featureIndex] = applied.feature;
2588
+ const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(input.supportingDocuments ?? []);
2589
+ const verifiedInput = { ...input, verifiedSupportingDocuments };
2590
+ const originalFeature = originalFeatures.features[featureIndex];
2591
+ const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
2265
2592
  try {
2266
- await this.saveFeatures(nextFeatures);
2593
+ // Keep the transaction granular: only the parent feature and target phase
2594
+ // belong to this handoff refresh. Rewriting the whole feature document can
2595
+ // disturb unrelated feature containment metadata.
2596
+ await this.saveFeature(applied.feature);
2267
2597
  await this.savePhase(applied.phase);
2598
+ const phase = await this.loadPhase(phaseId);
2599
+ const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
2600
+ const persistedHash = handoffContentHash(phase.handoff);
2601
+ if (phase.handoff !== applied.phase.handoff
2602
+ || !phase.handoffAudit
2603
+ || phase.handoffAudit.contentHash !== persistedHash
2604
+ || phase.handoffAudit.contentLength !== phase.handoff.length) {
2605
+ throw new HandoffContractError("HANDOFF_PERSISTENCE_VERIFICATION_FAILED", "The persisted handoff did not match the verified content. The write was rolled back.", { expectedHash: applied.phase.handoffAudit?.contentHash ?? "", actualHash: persistedHash });
2606
+ }
2607
+ return {
2608
+ phase,
2609
+ feature,
2610
+ updatedTaskIds: applied.updatedTaskIds,
2611
+ handoffUpdatedAt: phase.handoffUpdatedAt,
2612
+ handoffAudit: phase.handoffAudit,
2613
+ };
2268
2614
  }
2269
2615
  catch (error) {
2270
2616
  const rollbackErrors = [];
2271
- await this.saveFeatures(originalFeatures).catch((rollbackError) => rollbackErrors.push(rollbackError));
2272
- await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
2617
+ // Restore the exact parsed snapshots directly. Going through saveFeature /
2618
+ // savePhase would restamp description freshness against the failed write.
2619
+ await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
2620
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2621
+ await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
2622
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2273
2623
  if (rollbackErrors.length > 0) {
2274
2624
  throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
2275
2625
  }
2276
2626
  throw error;
2277
2627
  }
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
2628
  });
2282
2629
  }
2283
2630
  /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
@@ -2293,7 +2640,7 @@ export class PlanStore {
2293
2640
  await this.clearPhaseHandoff(phaseId, "superseded");
2294
2641
  }
2295
2642
  const now = new Date().toISOString();
2296
- await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
2643
+ await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now, handoffAudit: null }));
2297
2644
  }
2298
2645
  /** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
2299
2646
  * Does NOT clear it: read/load/show are non-mutating resume operations. */
@@ -2384,7 +2731,7 @@ export class PlanStore {
2384
2731
  }
2385
2732
  /** List only active/pending phase handoffs, newest first. Handoffs from
2386
2733
  * phases where every task is done/canceled are archived before returning. */
2387
- async listHandoffs() {
2734
+ async listHandoffs(options = {}) {
2388
2735
  await this.archiveStaleHandoffs();
2389
2736
  const phases = await this.loadAllPhases();
2390
2737
  const features = await this.loadFeatures();
@@ -2405,7 +2752,11 @@ export class PlanStore {
2405
2752
  compositeRef: formatPhaseRef(p.number, fnum),
2406
2753
  updatedAt: p.handoffUpdatedAt || p.updatedAt,
2407
2754
  firstLine: handoffFirstLine(p.handoff),
2408
- content: p.handoff,
2755
+ ...(options.includeContent ? { content: p.handoff } : {}),
2756
+ auditVersion: p.handoffAudit?.version ?? null,
2757
+ contentLength: p.handoff.length,
2758
+ contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
2759
+ verifiedAt: p.handoffAudit?.verifiedAt ?? "",
2409
2760
  });
2410
2761
  }
2411
2762
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -2503,14 +2854,15 @@ export class PlanStore {
2503
2854
  await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
2504
2855
  }
2505
2856
  // ── Workspace-level operations ─────────────────────────────────────
2506
- /** Load the full workspace (manifest + phases + project + requirements + features) */
2857
+ /** Load the full workspace, including the rollup-independent Ideas Inbox. */
2507
2858
  async loadWorkspace() {
2508
2859
  const manifest = await this.loadManifest();
2509
2860
  const phases = await this.loadAllPhases();
2510
2861
  const project = await this.loadProject();
2511
2862
  const features = await this.loadFeatures();
2512
2863
  const requirements = await this.loadRequirements();
2513
- return { manifest, phases, project, features, requirements };
2864
+ const ideas = await this.loadIdeas();
2865
+ return { manifest, phases, project, features, requirements, ideas };
2514
2866
  }
2515
2867
  // ── Markdown generation ────────────────────────────────────────────
2516
2868
  /** Load all data, render markdown, and write into generated/. Skips files