@agent-plan/core 0.2.29 → 0.3.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.
Files changed (92) hide show
  1. package/dist/accepted-decision-guard.d.ts +94 -0
  2. package/dist/accepted-decision-guard.d.ts.map +1 -0
  3. package/dist/accepted-decision-guard.js +110 -0
  4. package/dist/checklist.d.ts +28 -0
  5. package/dist/checklist.d.ts.map +1 -1
  6. package/dist/checklist.js +59 -0
  7. package/dist/feature-delete.d.ts +62 -0
  8. package/dist/feature-delete.d.ts.map +1 -0
  9. package/dist/feature-delete.js +49 -0
  10. package/dist/guard-decision.d.ts +128 -0
  11. package/dist/guard-decision.d.ts.map +1 -0
  12. package/dist/guard-decision.js +269 -0
  13. package/dist/guard-state.d.ts +14 -0
  14. package/dist/guard-state.d.ts.map +1 -0
  15. package/dist/guard-state.js +35 -0
  16. package/dist/handoff-context.d.ts +130 -1
  17. package/dist/handoff-context.d.ts.map +1 -1
  18. package/dist/handoff-context.js +167 -9
  19. package/dist/handoff-reply.d.ts +113 -0
  20. package/dist/handoff-reply.d.ts.map +1 -0
  21. package/dist/handoff-reply.js +279 -0
  22. package/dist/index.d.ts +18 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +18 -1
  25. package/dist/load-reply.d.ts +63 -0
  26. package/dist/load-reply.d.ts.map +1 -0
  27. package/dist/load-reply.js +64 -0
  28. package/dist/mutation-reply.d.ts +105 -0
  29. package/dist/mutation-reply.d.ts.map +1 -0
  30. package/dist/mutation-reply.js +203 -0
  31. package/dist/naming.d.ts +1 -1
  32. package/dist/naming.d.ts.map +1 -1
  33. package/dist/naming.js +1 -1
  34. package/dist/on-hold.d.ts +55 -0
  35. package/dist/on-hold.d.ts.map +1 -0
  36. package/dist/on-hold.js +123 -0
  37. package/dist/package-version.d.ts +13 -0
  38. package/dist/package-version.d.ts.map +1 -1
  39. package/dist/package-version.js +32 -0
  40. package/dist/pagination.d.ts +19 -0
  41. package/dist/pagination.d.ts.map +1 -0
  42. package/dist/pagination.js +15 -0
  43. package/dist/payload-fallback.js +1 -1
  44. package/dist/plan-store.d.ts +141 -8
  45. package/dist/plan-store.d.ts.map +1 -1
  46. package/dist/plan-store.js +459 -87
  47. package/dist/project-context-migration.d.ts +5 -0
  48. package/dist/project-context-migration.d.ts.map +1 -1
  49. package/dist/project-context-migration.js +13 -0
  50. package/dist/project-context.d.ts +397 -0
  51. package/dist/project-context.d.ts.map +1 -0
  52. package/dist/project-context.js +294 -0
  53. package/dist/recap.d.ts.map +1 -1
  54. package/dist/recap.js +33 -0
  55. package/dist/recommend-reply.d.ts +51 -0
  56. package/dist/recommend-reply.d.ts.map +1 -0
  57. package/dist/recommend-reply.js +93 -0
  58. package/dist/refs.d.ts +16 -0
  59. package/dist/refs.d.ts.map +1 -1
  60. package/dist/refs.js +38 -0
  61. package/dist/renderer.d.ts.map +1 -1
  62. package/dist/schema.d.ts +7 -2
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +8 -3
  65. package/dist/stranded-phase.d.ts +73 -0
  66. package/dist/stranded-phase.d.ts.map +1 -0
  67. package/dist/stranded-phase.js +93 -0
  68. package/dist/task-completion-gate.d.ts +84 -0
  69. package/dist/task-completion-gate.d.ts.map +1 -0
  70. package/dist/task-completion-gate.js +72 -0
  71. package/dist/task-context.d.ts +1 -0
  72. package/dist/task-context.d.ts.map +1 -1
  73. package/dist/task-context.js +42 -14
  74. package/dist/task-lifecycle.d.ts +12 -0
  75. package/dist/task-lifecycle.d.ts.map +1 -0
  76. package/dist/task-lifecycle.js +23 -0
  77. package/dist/task-order-context.d.ts +72 -0
  78. package/dist/task-order-context.d.ts.map +1 -0
  79. package/dist/task-order-context.js +82 -0
  80. package/dist/task-selection.d.ts +4 -1
  81. package/dist/task-selection.d.ts.map +1 -1
  82. package/dist/task-selection.js +36 -14
  83. package/dist/task-start-outcome.d.ts +26 -0
  84. package/dist/task-start-outcome.d.ts.map +1 -1
  85. package/dist/task-start-outcome.js +23 -0
  86. package/dist/text-bounds.d.ts +32 -0
  87. package/dist/text-bounds.d.ts.map +1 -0
  88. package/dist/text-bounds.js +43 -0
  89. package/dist/write-coordination.d.ts +2 -2
  90. package/dist/write-coordination.js +2 -2
  91. package/package.json +1 -1
  92. package/planner-skill.md +30 -12
@@ -21,13 +21,32 @@ import { buildHierarchicalDescriptionFreshness } from "./description-freshness.j
21
21
  import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
22
22
  import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
23
23
  import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
24
- import { applyHandoffContextSync, auditPhaseHandoff, externalizeOversizedHandoffContent, handoffContentHash, materializeHandoffMetadata, HandoffContractError, TARGET_HANDOFF_CONTENT_CHARS, validateHandoffReadBackVerification, } from "./handoff-context.js";
24
+ import { PROJECT_CONTEXT_DELIVERY_VERSION, createCompleteProjectContextDelivery, createProjectContextSnapshot, projectContextFingerprint, projectContextReadState, verifyProjectContextChunks, ProjectContextDeliveryError, ProjectContextReadStateDocumentSchema, } from "./project-context.js";
25
+ import { applyHandoffContextSync, auditPhaseHandoff, externalizeOversizedHandoffContent, extractHandoffSectionHeadings, handoffContentHash, isAutoExternalizedHandoffDocument, materializeHandoffMetadata, HandoffContractError, retainedHandoffContentNotFoundError, supportingDocumentInvalidError, TARGET_HANDOFF_CONTENT_CHARS, validateHandoffReadBackVerification, } from "./handoff-context.js";
25
26
  import { withPlanRootWriteLock } from "./write-coordination.js";
26
27
  import { ALLOCATION_REGISTRY_VERSION, PLAN_SCHEMA_VERSION, SUPPORTED_ALLOCATION_KINDS, isSupportedAllocationKind, unsupportedAllocationKindDetails, } from "./runtime-diagnostics.js";
27
- function nowISO() {
28
+ /** Current timestamp in ISO-8601. The one place that decides "now" for every
29
+ * planner write — adapters import this instead of each calling
30
+ * `new Date().toISOString()` themselves, so a future need to freeze or mock
31
+ * time has one call site to change. */
32
+ export function nowISO() {
28
33
  return new Date().toISOString();
29
34
  }
30
35
  const MAX_SESSION_INFO_ENTRIES = 16;
36
+ const RetainedHandoffDraftSchema = z.object({
37
+ phaseId: z.string(),
38
+ token: z.string(),
39
+ content: z.string(),
40
+ failureReason: z.string(),
41
+ retainedAt: z.string(),
42
+ });
43
+ // Bounds for retained handoff drafts, so a run of failed writes cannot grow
44
+ // .local/ without limit: at most this many distinct phase+token entries
45
+ // (oldest evicted first), each expiring after this age, with a defensive
46
+ // per-entry size ceiling far above MAX_HANDOFF_CONTENT_CHARS.
47
+ const MAX_RETAINED_HANDOFF_DRAFTS = 20;
48
+ const RETAINED_HANDOFF_DRAFT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
49
+ const MAX_RETAINED_HANDOFF_DRAFT_CONTENT_CHARS = 200_000;
31
50
  function normalizeAcceptedDecisionCreate(input, acceptedAt) {
32
51
  const title = input.title.trim();
33
52
  if (!title) {
@@ -114,6 +133,17 @@ function mergeSessionInfo(current, incoming) {
114
133
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
115
134
  .slice(0, MAX_SESSION_INFO_ENTRIES);
116
135
  }
136
+ function mergeProjectContextSessionInfo(current, incoming) {
137
+ const newestBySession = new Map();
138
+ for (const entry of [...(current ?? []), ...(incoming ?? [])]) {
139
+ const prior = newestBySession.get(entry.sessionId);
140
+ if (!prior || entry.createdAt > prior.createdAt)
141
+ newestBySession.set(entry.sessionId, entry);
142
+ }
143
+ return [...newestBySession.values()]
144
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
145
+ .slice(0, MAX_SESSION_INFO_ENTRIES);
146
+ }
117
147
  function assertSnapshotNotOlder(entity, entityId, incomingUpdatedAt, currentUpdatedAt) {
118
148
  if (incomingUpdatedAt < currentUpdatedAt) {
119
149
  throw new PlanStaleWriteError({
@@ -687,8 +717,6 @@ async function readJson(path, schema) {
687
717
  }
688
718
  export class PlanStore {
689
719
  root;
690
- autoSync = false;
691
- syncGuard = false;
692
720
  // While true, maybeAutoSync() is a no-op. Used by batch operations
693
721
  // (migrateToUuids, ensureStructureOrdering, syncStatuses, repair) so that
694
722
  // their internal savePhase/saveFeatures calls do NOT re-trigger a full
@@ -702,7 +730,7 @@ export class PlanStore {
702
730
  /** When enabled, status rollup (syncStatuses) runs automatically after every
703
731
  * phase/feature/project save. Used by the pi-adapter so the agent's tool
704
732
  * mutations keep phase/feature statuses derived from task statuses. */
705
- enableAutoSync(value) { this.autoSync = value; }
733
+ enableAutoSync(_value) { }
706
734
  /** Run a batch operation with autoSync suspended. Internal saves inside the
707
735
  * batch will NOT re-trigger syncStatuses (which would be O(N^2) on large
708
736
  * planners). The caller is responsible for triggering any needed final
@@ -1028,6 +1056,9 @@ export class PlanStore {
1028
1056
  activityPath() {
1029
1057
  return join(this.localRoot(), "activity.json");
1030
1058
  }
1059
+ projectContextReadsPath() {
1060
+ return join(this.localRoot(), "project-context-reads.json");
1061
+ }
1031
1062
  localRoot() {
1032
1063
  // Worktree-local, git-ignored runtime state. The shared-vs-local boundary
1033
1064
  // and the invariants every harness relies on are documented in
@@ -1063,6 +1094,9 @@ export class PlanStore {
1063
1094
  handoffArchiveDir() {
1064
1095
  return join(this.localRoot(), "handoff-archive");
1065
1096
  }
1097
+ handoffDraftsPath() {
1098
+ return join(this.localRoot(), "handoff-drafts.json");
1099
+ }
1066
1100
  /** One-time migration for plans created before .planner/.local/ existed.
1067
1101
  * Moves a legacy root-level file into .local/ if the legacy file exists and
1068
1102
  * the .local/ counterpart does not. Safe to call on every load. */
@@ -1677,7 +1711,6 @@ export class PlanStore {
1677
1711
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
1678
1712
  * dangling feature.phaseIds references. Idempotent. */
1679
1713
  async migratePhaseIds() {
1680
- const { readdir, unlink } = await import("node:fs/promises");
1681
1714
  const phases = await this.loadAllPhases();
1682
1715
  const features = await this.loadFeatures();
1683
1716
  // Infer missing featureId from feature.phaseIds references (legacy back-link).
@@ -2122,6 +2155,13 @@ export class PlanStore {
2122
2155
  const phase = await this.loadPhase(phaseId);
2123
2156
  let cleared = null;
2124
2157
  const terminalReason = this.terminalHandoffReason(phase);
2158
+ if (terminalReason) {
2159
+ // A terminal phase can never receive another handoff write, so any
2160
+ // draft retained against a stale/failed prepare token for it is dead
2161
+ // weight — discard it here regardless of whether there was an active
2162
+ // handoff body to archive below.
2163
+ await this.discardRetainedHandoffDrafts(phaseId);
2164
+ }
2125
2165
  if (terminalReason && phase.handoff !== "") {
2126
2166
  await this.clearPhaseHandoff(phaseId, terminalReason);
2127
2167
  const features = await this.loadFeatures();
@@ -2531,6 +2571,118 @@ export class PlanStore {
2531
2571
  return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
2532
2572
  });
2533
2573
  }
2574
+ async loadProjectContextReadStateDocument() {
2575
+ try {
2576
+ await access(this.projectContextReadsPath());
2577
+ }
2578
+ catch {
2579
+ return ProjectContextReadStateDocumentSchema.parse({});
2580
+ }
2581
+ return readJson(this.projectContextReadsPath(), ProjectContextReadStateDocumentSchema);
2582
+ }
2583
+ /**
2584
+ * Read project.json and requirements.json as one coherent pair with no
2585
+ * lock. A concurrent writer could otherwise interleave the two reads into
2586
+ * a torn pair (half old, half new); instead of blocking behind the plan
2587
+ * root write lock like a mutation, read twice and compare fingerprints,
2588
+ * retrying until two consecutive reads agree (bounded, so a pathological
2589
+ * writer cannot spin this forever).
2590
+ */
2591
+ async readCoherentProjectAndRequirements() {
2592
+ const maxAttempts = 5;
2593
+ const readOnce = async () => {
2594
+ const [project, requirements] = await Promise.all([
2595
+ readJson(this.projectPath(), ProjectSchema),
2596
+ readJson(this.requirementsPath(), RequirementsDocumentSchema),
2597
+ ]);
2598
+ return { project, requirements };
2599
+ };
2600
+ let current = await readOnce();
2601
+ for (let attempt = 1; attempt < maxAttempts; attempt++) {
2602
+ const currentFingerprint = projectContextFingerprint(createProjectContextSnapshot(current.project, current.requirements));
2603
+ const next = await readOnce();
2604
+ const nextFingerprint = projectContextFingerprint(createProjectContextSnapshot(next.project, next.requirements));
2605
+ if (nextFingerprint === currentFingerprint)
2606
+ return next;
2607
+ current = next;
2608
+ }
2609
+ return current;
2610
+ }
2611
+ async loadProjectContextDelivery(maxChars) {
2612
+ const { project, requirements } = await this.readCoherentProjectAndRequirements();
2613
+ return createCompleteProjectContextDelivery(project, requirements, maxChars);
2614
+ }
2615
+ async projectContextReadStateForSession(sessionId) {
2616
+ const { project, requirements } = await this.readCoherentProjectAndRequirements();
2617
+ const readState = await this.loadProjectContextReadStateDocument();
2618
+ return projectContextReadState(createProjectContextSnapshot(project, requirements), readState.sessionInfo, sessionId);
2619
+ }
2620
+ /**
2621
+ * Persist a worktree-local project-context read only after receiving the exact
2622
+ * complete chunk sequence for the current project and Requirements revisions.
2623
+ */
2624
+ /**
2625
+ * Governance decision (P102(F005) accepted decision): a project-context
2626
+ * read stamps sessionInfo on every requirement, the same way it stamps
2627
+ * every project Accepted Decision's parent (project.json) and Project
2628
+ * Guidelines — because the delivery this attests to is lossless and always
2629
+ * contains every requirement in full. This intentionally lets a
2630
+ * project-context read satisfy phase-scoped REQUIREMENTS_READ_REQUIRED
2631
+ * gates: the agent has genuinely seen the requirement, whichever surface
2632
+ * delivered it. `recordContextRead` stamps only the requirement ids it was
2633
+ * given because that call is scoped to one task's linked requirements, not
2634
+ * a full-project delivery.
2635
+ */
2636
+ async recordProjectContextRead(input) {
2637
+ const sessionId = input.sessionId.trim();
2638
+ if (!sessionId)
2639
+ throw new PlanStoreError("Project-context attestation requires a non-empty sessionId.");
2640
+ const delivery = verifyProjectContextChunks(input.chunks);
2641
+ const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
2642
+ const result = await this.runAsBatch(async () => {
2643
+ const project = await readJson(this.projectPath(), ProjectSchema);
2644
+ const requirements = await readJson(this.requirementsPath(), RequirementsDocumentSchema);
2645
+ const currentSnapshot = createProjectContextSnapshot(project, requirements);
2646
+ const currentFingerprint = projectContextFingerprint(currentSnapshot);
2647
+ if (delivery.evidence.fingerprint !== currentFingerprint) {
2648
+ throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_STALE", "Project context changed after delivery; reload every project-context chunk before attesting the read.");
2649
+ }
2650
+ let nextProject = project;
2651
+ if (project.projectGuidelines.content.trim()) {
2652
+ nextProject = {
2653
+ ...project,
2654
+ projectGuidelines: upsertSessionInfo(project.projectGuidelines, sessionId, createdAt).entity,
2655
+ };
2656
+ }
2657
+ // saveProject bumps touchTimestamp(), strips workDeviations, and merges
2658
+ // projectGuidelines.sessionInfo against the on-disk copy the same way
2659
+ // every other project write does.
2660
+ if (nextProject !== project)
2661
+ await this.saveProject(nextProject);
2662
+ let requirementsChanged = false;
2663
+ const nextRequirements = {
2664
+ requirements: requirements.requirements.map((requirement) => {
2665
+ const updated = upsertSessionInfo(requirement, sessionId, createdAt);
2666
+ requirementsChanged ||= updated.changed;
2667
+ return updated.entity;
2668
+ }),
2669
+ };
2670
+ // saveRequirements applies the stale-write guard and merges sessionInfo
2671
+ // per requirement against the on-disk copy, same as recordContextRead.
2672
+ if (requirementsChanged)
2673
+ await this.saveRequirements(nextRequirements);
2674
+ // The complete-read attestation is the commit marker and is written
2675
+ // last, after both save calls above have durably landed.
2676
+ const previousReadState = await this.loadProjectContextReadStateDocument();
2677
+ const nextReadState = ProjectContextReadStateDocumentSchema.parse({
2678
+ version: PROJECT_CONTEXT_DELIVERY_VERSION,
2679
+ sessionInfo: mergeProjectContextSessionInfo(previousReadState.sessionInfo, [{ sessionId, createdAt, fingerprint: currentFingerprint }]),
2680
+ });
2681
+ await atomicWriteJson(this.projectContextReadsPath(), nextReadState, this.root);
2682
+ return { project: nextProject, requirements: nextRequirements };
2683
+ });
2684
+ return { ...result, delivery, createdAt };
2685
+ }
2534
2686
  async recordProjectGuidelinesRead(input) {
2535
2687
  const sessionId = input.sessionId.trim();
2536
2688
  if (!sessionId)
@@ -2742,7 +2894,7 @@ export class PlanStore {
2742
2894
  /** Field-scoped task mutation against the lock-reloaded canonical phase. */
2743
2895
  async updateTask(phaseId, taskId, updater, options = {}) {
2744
2896
  let persisted;
2745
- const phase = await this.updatePhase(phaseId, (currentPhase) => {
2897
+ await this.updatePhase(phaseId, (currentPhase) => {
2746
2898
  const index = currentPhase.tasks.findIndex((task) => task.id === taskId);
2747
2899
  if (index < 0)
2748
2900
  throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
@@ -3042,31 +3194,111 @@ export class PlanStore {
3042
3194
  async getPhaseHandoff(phaseId) {
3043
3195
  return (await this.loadPhase(phaseId)).handoff;
3044
3196
  }
3045
- async validateHandoffSupportingDocuments(documents) {
3197
+ async loadRetainedHandoffDrafts() {
3198
+ try {
3199
+ return await readJson(this.handoffDraftsPath(), RetainedHandoffDraftSchema.array());
3200
+ }
3201
+ catch {
3202
+ return [];
3203
+ }
3204
+ }
3205
+ async saveRetainedHandoffDrafts(drafts) {
3206
+ await atomicWriteJson(this.handoffDraftsPath(), drafts, this.root);
3207
+ }
3208
+ isRetainedHandoffDraftFresh(draft) {
3209
+ const retainedAtMs = Date.parse(draft.retainedAt);
3210
+ return Number.isFinite(retainedAtMs) && Date.now() - retainedAtMs < RETAINED_HANDOFF_DRAFT_MAX_AGE_MS;
3211
+ }
3212
+ /** Persist the exact body submitted to refreshPhaseHandoff() against its
3213
+ * phase + prepare token, so a retry can omit `content` and reuse it
3214
+ * instead of resending a capsule up to MAX_HANDOFF_CONTENT_CHARS. Called
3215
+ * from refreshPhaseHandoff() on any write failure except a terminal phase
3216
+ * (nothing to retry there — see discardRetainedHandoffDrafts).
3217
+ *
3218
+ * Worktree-local only: .local/handoff-drafts.json, per the shared-vs-local
3219
+ * boundary (P069(F005)/T298, see localRoot()). A retained body must never
3220
+ * reach shared .planner/ state. It is a plain file, so it survives a
3221
+ * process restart in the same worktree — the failure and the retry can be
3222
+ * separated by a context compaction.
3223
+ *
3224
+ * Bounded so a run of failed writes cannot grow .local/ without limit:
3225
+ * one entry per phase+token (a new failure for the same token replaces
3226
+ * it), entries older than RETAINED_HANDOFF_DRAFT_MAX_AGE_MS are dropped,
3227
+ * and the total is capped at MAX_RETAINED_HANDOFF_DRAFTS (oldest evicted
3228
+ * first). A defensive per-entry size ceiling skips retention entirely for
3229
+ * runaway input rather than writing it to disk. */
3230
+ async retainHandoffDraft(phaseId, token, content, failureReason) {
3231
+ if (content.length > MAX_RETAINED_HANDOFF_DRAFT_CONTENT_CHARS)
3232
+ return;
3233
+ const drafts = (await this.loadRetainedHandoffDrafts())
3234
+ .filter((draft) => this.isRetainedHandoffDraftFresh(draft))
3235
+ .filter((draft) => !(draft.phaseId === phaseId && draft.token === token));
3236
+ drafts.push({ phaseId, token, content, failureReason, retainedAt: nowISO() });
3237
+ drafts.sort((a, b) => Date.parse(a.retainedAt) - Date.parse(b.retainedAt));
3238
+ while (drafts.length > MAX_RETAINED_HANDOFF_DRAFTS)
3239
+ drafts.shift();
3240
+ await this.saveRetainedHandoffDrafts(drafts);
3241
+ }
3242
+ /** Look up the retained draft for one phase + expectedHandoffUpdatedAt
3243
+ * token. An expired entry is treated as absent (and pruned on the next
3244
+ * retainHandoffDraft/discardRetainedHandoffDrafts write). */
3245
+ async getRetainedHandoffDraft(phaseId, token) {
3246
+ const draft = (await this.loadRetainedHandoffDrafts())
3247
+ .find((candidate) => candidate.phaseId === phaseId && candidate.token === token);
3248
+ if (!draft || !this.isRetainedHandoffDraftFresh(draft))
3249
+ return null;
3250
+ return draft;
3251
+ }
3252
+ /** Discard retained draft(s) for a phase. Pass `token` to discard exactly
3253
+ * the entry a successful write consumed; omit it to discard every draft
3254
+ * retained for the phase, regardless of token — used on handoff_clear and
3255
+ * when the phase reaches a terminal status (a terminal phase can never
3256
+ * receive another handoff write, so nothing retained for it is ever
3257
+ * useful again). */
3258
+ async discardRetainedHandoffDrafts(phaseId, token) {
3259
+ const drafts = await this.loadRetainedHandoffDrafts();
3260
+ const next = drafts.filter((draft) => draft.phaseId !== phaseId || (token !== undefined && draft.token !== token));
3261
+ if (next.length !== drafts.length)
3262
+ await this.saveRetainedHandoffDrafts(next);
3263
+ }
3264
+ /** Resolve a `.planner/docs/*.md` path to an absolute on-disk target,
3265
+ * enforcing the same format-and-containment rule everywhere a supporting
3266
+ * document path is trusted: it must look like a Markdown path under
3267
+ * `.planner/docs/` with no `..` segment, and must resolve inside the
3268
+ * docs directory. Shared by validateHandoffSupportingDocuments (strict:
3269
+ * throws on any defect) and readExternalizedHandoffDocument (lenient:
3270
+ * treats any defect, including one it did not have to make itself, as
3271
+ * "unavailable" — a persisted path is never re-trusted blindly). */
3272
+ resolveDocsTarget(rawPath) {
3273
+ const normalizedPath = rawPath.trim().replace(/\\/g, "/");
3274
+ if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
3275
+ throw supportingDocumentInvalidError(`Supporting document path must be a Markdown file under .planner/docs/: ${rawPath}`, { path: rawPath });
3276
+ }
3046
3277
  const docsRoot = resolve(this.root, "docs");
3278
+ const target = resolve(this.root, normalizedPath.slice(".planner/".length));
3279
+ if (!target.startsWith(`${docsRoot}${sep}`)) {
3280
+ throw supportingDocumentInvalidError(`Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
3281
+ }
3282
+ return target;
3283
+ }
3284
+ async validateHandoffSupportingDocuments(documents) {
3047
3285
  const metadata = [];
3048
3286
  const contents = [];
3049
3287
  const seen = new Set();
3050
3288
  for (const document of documents) {
3051
3289
  const normalizedPath = document.path.trim().replace(/\\/g, "/");
3052
- if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
3053
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
3054
- }
3055
3290
  if (seen.has(normalizedPath)) {
3056
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
3291
+ throw supportingDocumentInvalidError(`Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
3057
3292
  }
3058
3293
  seen.add(normalizedPath);
3059
- const target = resolve(this.root, normalizedPath.slice(".planner/".length));
3060
- if (!target.startsWith(`${docsRoot}${sep}`)) {
3061
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
3062
- }
3294
+ const target = this.resolveDocsTarget(document.path);
3063
3295
  const fileStat = await lstat(target).catch(() => null);
3064
3296
  if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
3065
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
3297
+ throw supportingDocumentInvalidError(`Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
3066
3298
  }
3067
3299
  const content = await readFile(target, "utf8").catch(() => null);
3068
3300
  if (content === null || !content.trim()) {
3069
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
3301
+ throw supportingDocumentInvalidError(`Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
3070
3302
  }
3071
3303
  metadata.push({
3072
3304
  path: normalizedPath,
@@ -3078,98 +3310,228 @@ export class PlanStore {
3078
3310
  }
3079
3311
  return { metadata, contents };
3080
3312
  }
3081
- /** Audit one exact phase before preparing a handoff refresh. */
3082
- async preparePhaseHandoff(phaseId) {
3313
+ /** Read back one document a previous handoff refresh auto-externalized,
3314
+ * for handoff_prepare to surface (see PhaseHandoffAudit.externalizedHandoffDocuments).
3315
+ * Unlike validateHandoffSupportingDocuments this never throws: a stale
3316
+ * path, a deleted file, a symlink, or any other defect is reported to the
3317
+ * caller as "missing" rather than failing the whole prepare call — the
3318
+ * document being gone is itself one of the edge cases this exists to
3319
+ * surface, not a reason to hide that it was ever there. */
3320
+ async readExternalizedHandoffDocument(rawPath) {
3321
+ try {
3322
+ const target = this.resolveDocsTarget(rawPath);
3323
+ const fileStat = await lstat(target).catch(() => null);
3324
+ if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile())
3325
+ return null;
3326
+ const content = await readFile(target, "utf8").catch(() => null);
3327
+ return content && content.trim() ? content : null;
3328
+ }
3329
+ catch {
3330
+ return null;
3331
+ }
3332
+ }
3333
+ /** The full prior capsule's `##` headings for validateHandoffSectionReconciliation
3334
+ * (P104(F005)/T416): the live phase's own compact-capsule headings, plus —
3335
+ * when there is a live handoff to reconcile against — the headings of
3336
+ * every document its own auto-externalization moved out, read back fresh
3337
+ * here (never the elided copy, matching PhaseHandoffAudit.priorSectionHeadings
3338
+ * as preparePhaseHandoff derives it). Empty when phase.handoff is "": a
3339
+ * first handoff has nothing prior, and a handoff already archived by
3340
+ * handoff_clear has nothing live left to reconcile even though stale
3341
+ * handoffAudit.supportingDocuments may still name an old externalized
3342
+ * file — this is what makes an archived-then-superseded write behave like
3343
+ * a first write instead of demanding dispositions for content that is
3344
+ * already gone. */
3345
+ async priorHandoffSectionHeadings(phase) {
3346
+ if (!phase.handoff.trim())
3347
+ return [];
3348
+ const headings = extractHandoffSectionHeadings(phase.handoff);
3349
+ const autoExternalizedDocuments = (phase.handoffAudit?.supportingDocuments ?? []).filter(isAutoExternalizedHandoffDocument);
3350
+ for (const document of autoExternalizedDocuments) {
3351
+ const content = await this.readExternalizedHandoffDocument(document.path);
3352
+ if (content)
3353
+ headings.push(...extractHandoffSectionHeadings(content));
3354
+ }
3355
+ return [...new Set(headings)];
3356
+ }
3357
+ /** Audit one exact phase before preparing a handoff refresh. When a
3358
+ * supporting-document manifest is already known (it is optional — most
3359
+ * handoffs need none), validate it here so every defect that does not
3360
+ * depend on the drafted body (bad path, missing file, symlink, duplicate,
3361
+ * empty file) fails before a token is issued and before any content is
3362
+ * drafted or transmitted. The same validation runs again at write time
3363
+ * (refreshPhaseHandoff), which stays the authority: it also catches a
3364
+ * document that was valid here but got deleted before the write, and the
3365
+ * one check that genuinely needs the drafted body (the content must link
3366
+ * each document's path). */
3367
+ async preparePhaseHandoff(phaseId, supportingDocuments) {
3083
3368
  const phase = await this.loadPhase(phaseId);
3084
3369
  if (!phase.featureId)
3085
3370
  throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
3086
3371
  const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === phase.featureId);
3087
3372
  if (!feature)
3088
3373
  throw new PlanStoreError(`Parent feature ${phase.featureId} not found for phase ${phaseId}.`);
3089
- return auditPhaseHandoff(phase, feature);
3374
+ if (supportingDocuments && supportingDocuments.length > 0) {
3375
+ await this.validateHandoffSupportingDocuments(supportingDocuments);
3376
+ }
3377
+ const audit = auditPhaseHandoff(phase, feature);
3378
+ // The active handoff's own auto-externalized documents are the elided
3379
+ // content this task exists to surface (see HANDOFF_AUTO_EXTERNALIZED_DOCUMENT_DESCRIPTION).
3380
+ // A document the agent linked deliberately has a different description
3381
+ // and is already named in the persisted capsule's own text; only the
3382
+ // tooling's own silent moves are read back here. Skipped entirely (no
3383
+ // I/O) when there is no prior handoff or nothing was ever externalized
3384
+ // from it — the common case.
3385
+ const autoExternalizedDocuments = (phase.handoffAudit?.supportingDocuments ?? [])
3386
+ .filter(isAutoExternalizedHandoffDocument);
3387
+ if (autoExternalizedDocuments.length === 0)
3388
+ return audit;
3389
+ const externalizedHandoffDocuments = await Promise.all(autoExternalizedDocuments.map(async (document) => {
3390
+ const content = await this.readExternalizedHandoffDocument(document.path);
3391
+ return {
3392
+ path: document.path,
3393
+ description: document.description,
3394
+ headings: content ? extractHandoffSectionHeadings(content) : [],
3395
+ content,
3396
+ contentLength: content?.length ?? document.contentLength,
3397
+ missing: content === null,
3398
+ };
3399
+ }));
3400
+ // A handoff already archived (handoff_clear) has phase.handoff === "" but
3401
+ // may still carry stale handoffAudit.supportingDocuments referencing its
3402
+ // old externalized file(s) — nothing left to reconcile against once
3403
+ // archived, so priorSectionHeadings stays whatever auditPhaseHandoff
3404
+ // already derived from the (empty) compact capsule: [].
3405
+ const priorSectionHeadings = phase.handoff.trim()
3406
+ ? [...new Set([...audit.priorSectionHeadings, ...externalizedHandoffDocuments.flatMap((document) => document.headings)])]
3407
+ : audit.priorSectionHeadings;
3408
+ return { ...audit, externalizedHandoffDocuments, priorSectionHeadings };
3090
3409
  }
3091
3410
  /** Refresh the single active handoff and synchronize durable task/phase/feature
3092
3411
  * context. Unlike setPhaseHandoff(), this deliberately does not archive the
3093
3412
  * previous active body: callers must reconcile it using the optimistic
3094
- * handoffUpdatedAt token returned by preparePhaseHandoff(). */
3413
+ * handoffUpdatedAt token returned by preparePhaseHandoff().
3414
+ *
3415
+ * `content` is optional. When omitted, the body submitted on the last
3416
+ * failed write against this exact phase + expectedHandoffUpdatedAt token
3417
+ * is reused (throws HANDOFF_RETAINED_CONTENT_NOT_FOUND when none is
3418
+ * retained). This is what makes a retry after any late write failure —
3419
+ * stale token, unresolved placeholders, a deleted supporting document, a
3420
+ * persisted read-back hash mismatch, or anything else — cheap instead of
3421
+ * resending a capsule up to MAX_HANDOFF_CONTENT_CHARS: the body is sent
3422
+ * once, and every other field on a retry is already small. On any failure
3423
+ * below (other than the terminal-phase check, which discards instead —
3424
+ * a terminal phase can never accept a retry) the content actually used is
3425
+ * retained again for the next attempt; on success the entry for this
3426
+ * token is discarded. */
3095
3427
  async refreshPhaseHandoff(phaseId, input) {
3096
3428
  return this.runAsBatch(async () => {
3097
3429
  const originalPhase = await this.loadPhase(phaseId);
3098
3430
  if (this.terminalHandoffReason(originalPhase)) {
3431
+ await this.discardRetainedHandoffDrafts(phaseId);
3099
3432
  throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
3100
3433
  }
3101
3434
  if (!originalPhase.featureId)
3102
3435
  throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
3103
- const originalFeatures = await this.loadFeatures();
3104
- const featureIndex = originalFeatures.features.findIndex((candidate) => candidate.id === originalPhase.featureId);
3105
- if (featureIndex < 0)
3106
- throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
3107
- const timestamp = nowISO();
3108
- const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
3109
- const materializedContent = materializeHandoffMetadata(input.content, input.reason, existingCreatedAt, timestamp);
3110
- let effectiveInput = { ...input, content: materializedContent };
3111
- if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
3112
- const stamp = timestamp.replace(/[:.]/g, "-");
3113
- const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
3114
- const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
3115
- if (externalized.externalized) {
3116
- const docsDir = resolve(this.root, "docs");
3117
- await mkdir(docsDir, { recursive: true });
3118
- const target = resolve(this.root, autoPath.slice(".planner/".length));
3119
- if (!target.startsWith(`${docsDir}${sep}`)) {
3120
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
3121
- }
3122
- await writeFile(target, externalized.extendedContent, "utf8");
3123
- const autoDoc = externalized.supportingDocument;
3124
- effectiveInput = {
3125
- ...input,
3126
- content: externalized.content,
3127
- supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
3128
- };
3129
- }
3436
+ const submittedContent = input.content?.trim() ? input.content : undefined;
3437
+ let content;
3438
+ if (submittedContent !== undefined) {
3439
+ content = submittedContent;
3440
+ }
3441
+ else {
3442
+ const retained = await this.getRetainedHandoffDraft(phaseId, input.expectedHandoffUpdatedAt);
3443
+ if (!retained)
3444
+ throw retainedHandoffContentNotFoundError(phaseId, input.expectedHandoffUpdatedAt);
3445
+ content = retained.content;
3130
3446
  }
3131
- const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
3132
- const verifiedInput = {
3133
- ...effectiveInput,
3134
- verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
3135
- verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
3136
- };
3137
- const originalFeature = originalFeatures.features[featureIndex];
3138
- const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
3139
3447
  try {
3140
- // Keep the transaction granular: only the parent feature and target phase
3141
- // belong to this handoff refresh. Rewriting the whole feature document can
3142
- // disturb unrelated feature containment metadata.
3143
- await this.saveFeature(applied.feature);
3144
- await this.savePhase(applied.phase);
3145
- const phase = await this.loadPhase(phaseId);
3146
- const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
3147
- const persistedHash = handoffContentHash(phase.handoff);
3148
- if (phase.handoff !== applied.phase.handoff
3149
- || !phase.handoffAudit
3150
- || phase.handoffAudit.contentHash !== persistedHash
3151
- || phase.handoffAudit.contentLength !== phase.handoff.length) {
3152
- 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 });
3448
+ const originalFeatures = await this.loadFeatures();
3449
+ const featureIndex = originalFeatures.features.findIndex((candidate) => candidate.id === originalPhase.featureId);
3450
+ if (featureIndex < 0)
3451
+ throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
3452
+ const timestamp = nowISO();
3453
+ const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
3454
+ const materializedContent = materializeHandoffMetadata(content, input.reason, existingCreatedAt, timestamp);
3455
+ let effectiveInput = { ...input, content: materializedContent };
3456
+ if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
3457
+ const stamp = timestamp.replace(/[:.]/g, "-");
3458
+ const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
3459
+ const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
3460
+ if (externalized.externalized) {
3461
+ const docsDir = resolve(this.root, "docs");
3462
+ await mkdir(docsDir, { recursive: true });
3463
+ const target = resolve(this.root, autoPath.slice(".planner/".length));
3464
+ if (!target.startsWith(`${docsDir}${sep}`)) {
3465
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
3466
+ }
3467
+ await writeFile(target, externalized.extendedContent, "utf8");
3468
+ const autoDoc = externalized.supportingDocument;
3469
+ effectiveInput = {
3470
+ ...input,
3471
+ content: externalized.content,
3472
+ // The agent linked its documents in the body it submitted; this
3473
+ // rewrite may have moved those links into the external file. Carry
3474
+ // the original so validation judges the author's own linking.
3475
+ submittedContent: materializedContent,
3476
+ supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
3477
+ };
3478
+ }
3153
3479
  }
3154
- return {
3155
- phase,
3156
- feature,
3157
- updatedTaskIds: applied.updatedTaskIds,
3158
- handoffUpdatedAt: phase.handoffUpdatedAt,
3159
- handoffAudit: phase.handoffAudit,
3480
+ const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
3481
+ // Derived from the live originalPhase, not a copy captured at
3482
+ // prepare time — see priorHandoffSectionHeadings for why that is
3483
+ // exactly what "the prior capsule the author was shown" means even
3484
+ // across an intervening handoff_clear.
3485
+ const priorSectionHeadings = await this.priorHandoffSectionHeadings(originalPhase);
3486
+ const verifiedInput = {
3487
+ ...effectiveInput,
3488
+ verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
3489
+ verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
3490
+ priorSectionHeadings,
3160
3491
  };
3492
+ const originalFeature = originalFeatures.features[featureIndex];
3493
+ const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
3494
+ try {
3495
+ // Keep the transaction granular: only the parent feature and target phase
3496
+ // belong to this handoff refresh. Rewriting the whole feature document can
3497
+ // disturb unrelated feature containment metadata.
3498
+ await this.saveFeature(applied.feature);
3499
+ await this.savePhase(applied.phase);
3500
+ const phase = await this.loadPhase(phaseId);
3501
+ const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
3502
+ const persistedHash = handoffContentHash(phase.handoff);
3503
+ if (phase.handoff !== applied.phase.handoff
3504
+ || !phase.handoffAudit
3505
+ || phase.handoffAudit.contentHash !== persistedHash
3506
+ || phase.handoffAudit.contentLength !== phase.handoff.length) {
3507
+ 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 });
3508
+ }
3509
+ await this.discardRetainedHandoffDrafts(phaseId, input.expectedHandoffUpdatedAt);
3510
+ return {
3511
+ phase,
3512
+ feature,
3513
+ updatedTaskIds: applied.updatedTaskIds,
3514
+ handoffUpdatedAt: phase.handoffUpdatedAt,
3515
+ handoffAudit: phase.handoffAudit,
3516
+ };
3517
+ }
3518
+ catch (error) {
3519
+ const rollbackErrors = [];
3520
+ // Restore the exact parsed snapshots directly. Going through saveFeature /
3521
+ // savePhase would restamp description freshness against the failed write.
3522
+ await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
3523
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
3524
+ await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
3525
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
3526
+ if (rollbackErrors.length > 0) {
3527
+ throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
3528
+ }
3529
+ throw error;
3530
+ }
3161
3531
  }
3162
3532
  catch (error) {
3163
- const rollbackErrors = [];
3164
- // Restore the exact parsed snapshots directly. Going through saveFeature /
3165
- // savePhase would restamp description freshness against the failed write.
3166
- await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
3167
- .catch((rollbackError) => rollbackErrors.push(rollbackError));
3168
- await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
3169
- .catch((rollbackError) => rollbackErrors.push(rollbackError));
3170
- if (rollbackErrors.length > 0) {
3171
- throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
3172
- }
3533
+ const failureReason = error instanceof Error ? error.message : String(error);
3534
+ await this.retainHandoffDraft(phaseId, input.expectedHandoffUpdatedAt, content, failureReason);
3173
3535
  throw error;
3174
3536
  }
3175
3537
  });
@@ -3261,6 +3623,9 @@ export class PlanStore {
3261
3623
  * "manual" | "superseded" | "imported". */
3262
3624
  async clearPhaseHandoff(phaseId, reason = "manual") {
3263
3625
  await this.migrateLegacyHandoffArchive();
3626
+ // Any draft retained against a failed write for this phase is now moot,
3627
+ // whether or not there was an active handoff body to archive below.
3628
+ await this.discardRetainedHandoffDrafts(phaseId);
3264
3629
  const phase = await this.loadPhase(phaseId).catch(() => null);
3265
3630
  if (!phase || phase.handoff === "")
3266
3631
  return; // nothing to archive
@@ -3291,7 +3656,14 @@ export class PlanStore {
3291
3656
  let archived = 0;
3292
3657
  for (const phase of phases) {
3293
3658
  const reason = this.terminalHandoffReason(phase);
3294
- if (reason && phase.handoff) {
3659
+ if (!reason)
3660
+ continue;
3661
+ // clearPhaseHandoff() already discards retained drafts, but only runs
3662
+ // below when there is a body to archive; a terminal phase that never
3663
+ // had a successful write (failed attempts only) still needs its
3664
+ // retained draft swept here.
3665
+ await this.discardRetainedHandoffDrafts(phase.id);
3666
+ if (phase.handoff) {
3295
3667
  await this.clearPhaseHandoff(phase.id, reason);
3296
3668
  archived += 1;
3297
3669
  }