@popoverai/dotrequirements 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@ export async function diffCommand(scope, options) {
28
28
  // DIFF-2: any scope argument means document-scope → verbose. No scope means
29
29
  // project-scope → verdict list.
30
30
  const verbose = scope.length > 0;
31
- const notInSync = documents.filter((d) => d.verdict !== "in_sync");
31
+ const notInSync = documents.filter((d) => d.verdict !== "in_sync" || d.attachments?.differ);
32
32
  // DIFF-1.2: don't list every document when everything is in sync.
33
33
  if (notInSync.length === 0 && documents.length > 0) {
34
34
  console.log(`✓ Repo and ${brand} cloud are in sync (${documents.length} document(s)).`);
@@ -49,6 +49,11 @@ export async function diffCommand(scope, options) {
49
49
  console.log(formatUnitDetail(detail));
50
50
  }
51
51
  }
52
+ if (verbose && doc.attachments?.differ) {
53
+ for (const detail of doc.attachments.details) {
54
+ console.log(formatUnitDetail(detail));
55
+ }
56
+ }
52
57
  if (doc.verdict === "conflict") {
53
58
  console.log(` ${resolutionHint(doc)}`);
54
59
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import * as readline from "node:readline";
9
+ import { attachmentSyncNeeded } from "../sync/attachments.js";
9
10
  import { executePlan } from "../sync/execute.js";
10
11
  import { acquireCloudSnapshot, acquireLocalSnapshot, compareSnapshots, duplicateIdAbortMessage, filterByScope, resolutionHint, } from "../sync/index.js";
11
12
  import { buildPlan, resolveMode, } from "../sync/plan.js";
@@ -72,22 +73,52 @@ export async function syncCommand(scope, options) {
72
73
  console.log(`No document matched scope "${token}".`);
73
74
  }
74
75
  const plan = buildPlan(documents, mode);
75
- // SYNC-MODE-4: name every deletion before writing anything.
76
+ // SYNC-MODE-4 gates WHOLE-DOCUMENT deletions only. Content-level removals
77
+ // ride authority ungated — a requirement block dropped from a file is
78
+ // deleted from the cloud by the same run without a prompt, and attachments
79
+ // follow that rule, not the document rule (an attached link is also the
80
+ // most recoverable thing here: re-paste the URL). The docs promise
81
+ // "attachments never hold up the rest of the spec"; a consent gate on
82
+ // their removal held up the entire sync (decided 2026-08-20). What
83
+ // authority removed is NAMED after the run instead — see the
84
+ // attachmentRemovals report below.
76
85
  const deletions = plan.filter((p) => p.action === "cloud_delete" || p.action === "local_delete");
77
86
  if (deletions.length > 0) {
87
+ // SYNC-MODE-4.0: every deletion is NAMED before anything else happens —
88
+ // in the non-TTY refusal too, or the operator is told three documents
89
+ // will be destroyed with no way to learn which three.
90
+ printDeletionNotice(deletions);
78
91
  // SYNC-MODE-4.3: a non-interactive run cannot consent — refuse rather than
79
92
  // hanging on a prompt that will never answer (or worse, deleting silently).
80
93
  if (!options.yes && !process.stdin.isTTY) {
81
- throw new Error(`This sync would delete ${deletions.length} document(s), and there is no terminal to confirm on. ` +
82
- `Re-run with --yes to consent to the deletions listed by \`dotreq diff\`.`);
94
+ throw new Error(`This sync would delete ${deletions.length} document(s), and there is no terminal ` +
95
+ `to confirm on. Re-run with --yes to consent to the deletions listed above.`);
83
96
  }
84
- if (!(await confirmDeletions(deletions, options.yes))) {
97
+ if (!(await confirmDeletions(options.yes))) {
85
98
  console.log("Sync cancelled.");
86
99
  return "cancelled";
87
100
  }
88
101
  }
89
- if (plan.every((p) => p.action === "skip")) {
102
+ // ATTACH-10.4: a difference this mode deliberately leaves alone (a no-key
103
+ // file under repo authority, a duplicated legacy cloud row under cloud
104
+ // authority) would otherwise leave `dotreq diff --exit-code` red with
105
+ // nothing in this mode ever clearing it — say so and name the gesture
106
+ // that repairs it, on EVERY run that leaves one behind, not only when the
107
+ // sync had nothing else to do (the round-1 release review's finding).
108
+ const leftAlone = plan.filter((p) => p.doc.attachments?.differ &&
109
+ !attachmentSyncNeeded(p.doc.attachments, mode));
110
+ const printLeftAlone = () => {
111
+ if (leftAlone.length > 0) {
112
+ console.log(` (${leftAlone.length} document(s) have attachment differences this mode leaves alone — a repo-writing sync, e.g. plain \`dotreq sync\`, reconciles them.)`);
113
+ }
114
+ };
115
+ // ATTACH-10.2: attachments resolve outside the body's verdict, so "every
116
+ // body action is skip" is not "nothing to do" — the resolver, per mode,
117
+ // is what knows whether attachment work remains. Without this, an
118
+ // attachment-only difference reported by diff would never clear.
119
+ if (plan.every((p) => p.action === "skip" && !attachmentSyncNeeded(p.doc.attachments, mode))) {
90
120
  console.log(`✓ Repo and ${brand} cloud are already in sync.`);
121
+ printLeftAlone();
91
122
  return "clean";
92
123
  }
93
124
  console.log(`\nSyncing with ${brand} cloud...`);
@@ -103,6 +134,17 @@ export async function syncCommand(scope, options) {
103
134
  console.log(` Written locally: ${outcome.localWritten}`);
104
135
  if (outcome.localDeleted)
105
136
  console.log(` Deleted locally: ${outcome.localDeleted}`);
137
+ if (outcome.attachmentPushes)
138
+ console.log(` Attachments updated in cloud: ${outcome.attachmentPushes}`);
139
+ if (outcome.attachmentWrites)
140
+ console.log(` Attachments updated locally: ${outcome.attachmentWrites}`);
141
+ // ATTACH-10.3.1.0: authority removals are a trace, not a consent gate —
142
+ // named after the push that actually performed them, like renames, so the
143
+ // report never claims a removal a failed push left in place.
144
+ for (const r of outcome.attachmentRemovals) {
145
+ console.log(` removed attached link on "${r.name}": ${r.title ?? r.url}`);
146
+ }
147
+ printLeftAlone();
106
148
  for (const conflict of outcome.conflicts) {
107
149
  console.log(` ! conflict: ${path.basename(conflict.filePath ?? conflict.title)} — ${resolutionHint(conflict)}`);
108
150
  }
@@ -178,7 +220,7 @@ export async function syncCommand(scope, options) {
178
220
  }
179
221
  return hadTrouble ? "trouble" : "clean";
180
222
  }
181
- async function confirmDeletions(deletions, skip) {
223
+ function printDeletionNotice(deletions) {
182
224
  const cloudDeletes = deletions.filter((d) => d.action === "cloud_delete");
183
225
  const localDeletes = deletions.filter((d) => d.action === "local_delete");
184
226
  console.log("\nThis will PERMANENTLY delete:");
@@ -188,6 +230,8 @@ async function confirmDeletions(deletions, skip) {
188
230
  for (const d of localDeletes) {
189
231
  console.log(` from disk: ${d.doc.filePath ? path.basename(d.doc.filePath) : d.doc.title}`);
190
232
  }
233
+ }
234
+ async function confirmDeletions(skip) {
191
235
  if (skip) {
192
236
  console.log("(--yes: proceeding without confirmation)");
193
237
  return true;
package/dist/convex.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare const api: {
25
25
  create: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
26
26
  update: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
27
27
  deleteForCli: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
28
+ setAttachmentsForSync: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
28
29
  };
29
30
  saveWithRequirements: {
30
31
  saveWithRequirements: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
package/dist/convex.js CHANGED
@@ -30,6 +30,8 @@ export const api = {
30
30
  update: mutation("documents/mutations:update"),
31
31
  // SYNC-MODE-3: delete a cloud document from the CLI (repo-wins)
32
32
  deleteForCli: mutation("documents/mutations:deleteForCli"),
33
+ // ATTACH-10.2: attachment-only sync — a metadata patch, never a publish
34
+ setAttachmentsForSync: mutation("documents/mutations:setAttachmentsForSync"),
33
35
  },
34
36
  // SYNC-ARCH-1: Save document with requirements derivation
35
37
  saveWithRequirements: {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * MULTISET equality for attachment lists: order never matters, names and
3
+ * duplicates do. The predecessor was a Map probe — asymmetric
4
+ * (eq([A,B],[A,A]) was true) — which silently disarmed the compare-and-set
5
+ * on duplicated legacy rows.
6
+ *
7
+ * Dependency-free and in schema/ so BOTH halves of the compare-and-set import
8
+ * the same function (the CLI resolver via sync/attachments, the Convex
9
+ * mutations via lib/attachments — the same cross-package path testCoverage
10
+ * already uses): two byte-identical copies would drift the moment one is
11
+ * tightened, and a drifted pair reports "attachments changed since the sync
12
+ * read them" on every run with nothing moving.
13
+ */
14
+ export declare function attachmentListsEqual(a: Array<{
15
+ url: string;
16
+ title?: string;
17
+ }>, b: Array<{
18
+ url: string;
19
+ title?: string;
20
+ }>): boolean;
21
+ //# sourceMappingURL=attachment-lists.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * MULTISET equality for attachment lists: order never matters, names and
3
+ * duplicates do. The predecessor was a Map probe — asymmetric
4
+ * (eq([A,B],[A,A]) was true) — which silently disarmed the compare-and-set
5
+ * on duplicated legacy rows.
6
+ *
7
+ * Dependency-free and in schema/ so BOTH halves of the compare-and-set import
8
+ * the same function (the CLI resolver via sync/attachments, the Convex
9
+ * mutations via lib/attachments — the same cross-package path testCoverage
10
+ * already uses): two byte-identical copies would drift the moment one is
11
+ * tightened, and a drifted pair reports "attachments changed since the sync
12
+ * read them" on every run with nothing moving.
13
+ */
14
+ export function attachmentListsEqual(a, b) {
15
+ if (a.length !== b.length)
16
+ return false;
17
+ const key = (x) => `${x.url}\u0000${x.title ?? ""}`;
18
+ const as = a.map(key).sort();
19
+ const bs = b.map(key).sort();
20
+ return as.every((v, i) => v === bs[i]);
21
+ }
22
+ //# sourceMappingURL=attachment-lists.js.map
@@ -10,6 +10,7 @@ export { DELIMITER_PATTERN, type ExtractedRequirementBlock, extractRequirementBl
10
10
  export type { Metadata, ParsedCriterion, RequirementKey, RequirementNode, RequirementPrefix, RequirementsFile, } from "./schemas.js";
11
11
  export { buildRequirementKey, canonicalRequirementKey, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
12
12
  export { composeMarkdownWithTitle, effectiveTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
13
+ export { attachmentListsEqual } from "./attachment-lists.js";
13
14
  export type { BuildScenarioOptions, Scenario, ScenarioAssertionSource, ScenarioStep, } from "./scenario.js";
14
15
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
15
16
  //# sourceMappingURL=browser.d.ts.map
@@ -25,6 +25,11 @@ ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirem
25
25
  // Title semantics (pure TypeScript - browser-safe): the document title and
26
26
  // the markdown's leading H1 are the same thing (DOC-TITLE-3)
27
27
  export { composeMarkdownWithTitle, effectiveTitle, splitLeadingH1, } from "./title-markdown.js";
28
+ // NOTE: parser.ts and resolver.ts are excluded because they use Node.js 'fs' module.
29
+ // Use parser-core.ts functions above for browser/Convex environments.
30
+ // Dependency-free; shared with the Convex mutations so both halves of the
31
+ // attachments compare-and-set use one predicate (see attachment-lists.ts).
32
+ export { attachmentListsEqual } from "./attachment-lists.js";
28
33
  // Scenario building (pure TypeScript - browser-safe, used by Convex Node actions)
29
34
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
30
35
  //# sourceMappingURL=browser.js.map
@@ -27,6 +27,23 @@ export type PrefixDirective =
27
27
  | "keep"
28
28
  /** Remove it — the authoritative side has no prefix. */
29
29
  | "clear";
30
+ /**
31
+ * What this write does to `document.attachments` (ATTACH-10) — the same
32
+ * three readings the prefix has, for the same reason: an absent cloud list is
33
+ * not self-interpreting.
34
+ */
35
+ export type AttachmentsDirective =
36
+ /** The cloud has attachments; the file takes the list. */
37
+ {
38
+ set: Array<{
39
+ url: string;
40
+ title?: string;
41
+ }>;
42
+ }
43
+ /** Leave whatever the file says. */
44
+ | "keep"
45
+ /** Remove them — the authoritative side has none. */
46
+ | "clear";
30
47
  /**
31
48
  * The frontmatter fields sync owns and rewrites on every write
32
49
  * (DOC-HEADER-14.1). `pulledAt` is owned too but never passed: it always
@@ -37,6 +54,10 @@ export interface OwnedFrontmatter {
37
54
  documentId: string;
38
55
  /** Editor hint for new requirement keys — see PrefixDirective. */
39
56
  defaultPrefix: PrefixDirective;
57
+ /** The document's attached links — see AttachmentsDirective. Absent means
58
+ * "keep": a caller that doesn't think about attachments must not clear
59
+ * them. */
60
+ attachments?: AttachmentsDirective;
40
61
  /** The cloud version this content is at. */
41
62
  version: number;
42
63
  }
@@ -43,6 +43,13 @@ export function writeRequirementsFile(filePath, owned, body) {
43
43
  frontmatter.deleteIn(["document", "defaultPrefix"]);
44
44
  else if (owned.defaultPrefix !== "keep")
45
45
  frontmatter.setIn(["document", "defaultPrefix"], owned.defaultPrefix.set);
46
+ const attachments = owned.attachments ?? "keep";
47
+ if (attachments === "clear")
48
+ frontmatter.deleteIn(["document", "attachments"]);
49
+ else if (attachments !== "keep")
50
+ frontmatter.setIn(["document", "attachments"],
51
+ // Omit absent titles rather than writing `title: null` into the file.
52
+ attachments.set.map((a) => a.title === undefined ? { url: a.url } : { url: a.url, title: a.title }));
46
53
  // SYNC-TITLE-1.2: `document.title` is a recognized-but-retired field — the
47
54
  // title lives in the body as its leading H1 — so a rewrite drops it rather
48
55
  // than carrying it forward as if it were one of the user's own fields.
@@ -4,10 +4,11 @@
4
4
  * This module provides types, validation, parsing, and building utilities
5
5
  * for the Markdown requirements format.
6
6
  */
7
+ export { attachmentListsEqual } from "./attachment-lists.js";
7
8
  export { buildRequirementMarkdown, buildRequirementsFile, buildRequirementsMarkdown, } from "./builder.js";
8
9
  export type { ConvexRequirement } from "./conversions.js";
9
10
  export { buildMetadata, constructKey, convexToRequirements, extractRequirementKeys, groupByRoot, parseKey, requirementsToConvex, } from "./conversions.js";
10
- export type { OwnedFrontmatter, PrefixDirective } from "./file-writer.js";
11
+ export type { AttachmentsDirective, OwnedFrontmatter, PrefixDirective, } from "./file-writer.js";
11
12
  export { writeRequirementsFile } from "./file-writer.js";
12
13
  export type { ExtractedRequirementBlock } from "./parser.js";
13
14
  export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRequirementsFile, parseRequirementsFromFile, parseRootLine, splitRequirementFenceContent, } from "./parser.js";
@@ -4,6 +4,7 @@
4
4
  * This module provides types, validation, parsing, and building utilities
5
5
  * for the Markdown requirements format.
6
6
  */
7
+ export { attachmentListsEqual } from "./attachment-lists.js";
7
8
  // Building
8
9
  export { buildRequirementMarkdown, buildRequirementsFile, buildRequirementsMarkdown, } from "./builder.js";
9
10
  export { buildMetadata, constructKey, convexToRequirements, extractRequirementKeys, groupByRoot, parseKey, requirementsToConvex, } from "./conversions.js";
@@ -86,14 +86,44 @@ export declare const MetadataSchema: z.ZodObject<{
86
86
  id: z.ZodOptional<z.ZodString>;
87
87
  title: z.ZodOptional<z.ZodString>;
88
88
  defaultPrefix: z.ZodOptional<z.ZodString>;
89
+ attachments: z.ZodEffects<z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
90
+ url: z.ZodString;
91
+ title: z.ZodOptional<z.ZodString>;
92
+ }, "strip", z.ZodTypeAny, {
93
+ url: string;
94
+ title?: string | undefined;
95
+ }, {
96
+ url: string;
97
+ title?: string | undefined;
98
+ }>, "many">>>, {
99
+ url: string;
100
+ title?: string | undefined;
101
+ }[] | undefined, {
102
+ url: string;
103
+ title?: string | undefined;
104
+ }[] | null | undefined>, {
105
+ url: string;
106
+ title?: string | undefined;
107
+ }[] | undefined, {
108
+ url: string;
109
+ title?: string | undefined;
110
+ }[] | null | undefined>;
89
111
  }, "strip", z.ZodTypeAny, {
90
112
  id?: string | undefined;
91
113
  title?: string | undefined;
92
114
  defaultPrefix?: string | undefined;
115
+ attachments?: {
116
+ url: string;
117
+ title?: string | undefined;
118
+ }[] | undefined;
93
119
  }, {
94
120
  id?: string | undefined;
95
121
  title?: string | undefined;
96
122
  defaultPrefix?: string | undefined;
123
+ attachments?: {
124
+ url: string;
125
+ title?: string | undefined;
126
+ }[] | null | undefined;
97
127
  }>>;
98
128
  }, "strip", z.ZodTypeAny, {
99
129
  version?: number | undefined;
@@ -103,6 +133,10 @@ export declare const MetadataSchema: z.ZodObject<{
103
133
  id?: string | undefined;
104
134
  title?: string | undefined;
105
135
  defaultPrefix?: string | undefined;
136
+ attachments?: {
137
+ url: string;
138
+ title?: string | undefined;
139
+ }[] | undefined;
106
140
  } | undefined;
107
141
  }, {
108
142
  version?: number | undefined;
@@ -112,6 +146,10 @@ export declare const MetadataSchema: z.ZodObject<{
112
146
  id?: string | undefined;
113
147
  title?: string | undefined;
114
148
  defaultPrefix?: string | undefined;
149
+ attachments?: {
150
+ url: string;
151
+ title?: string | undefined;
152
+ }[] | null | undefined;
115
153
  } | undefined;
116
154
  }>;
117
155
  export type Metadata = z.infer<typeof MetadataSchema>;
@@ -175,14 +213,44 @@ export declare const RequirementsFileSchema: z.ZodObject<{
175
213
  id: z.ZodOptional<z.ZodString>;
176
214
  title: z.ZodOptional<z.ZodString>;
177
215
  defaultPrefix: z.ZodOptional<z.ZodString>;
216
+ attachments: z.ZodEffects<z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
217
+ url: z.ZodString;
218
+ title: z.ZodOptional<z.ZodString>;
219
+ }, "strip", z.ZodTypeAny, {
220
+ url: string;
221
+ title?: string | undefined;
222
+ }, {
223
+ url: string;
224
+ title?: string | undefined;
225
+ }>, "many">>>, {
226
+ url: string;
227
+ title?: string | undefined;
228
+ }[] | undefined, {
229
+ url: string;
230
+ title?: string | undefined;
231
+ }[] | null | undefined>, {
232
+ url: string;
233
+ title?: string | undefined;
234
+ }[] | undefined, {
235
+ url: string;
236
+ title?: string | undefined;
237
+ }[] | null | undefined>;
178
238
  }, "strip", z.ZodTypeAny, {
179
239
  id?: string | undefined;
180
240
  title?: string | undefined;
181
241
  defaultPrefix?: string | undefined;
242
+ attachments?: {
243
+ url: string;
244
+ title?: string | undefined;
245
+ }[] | undefined;
182
246
  }, {
183
247
  id?: string | undefined;
184
248
  title?: string | undefined;
185
249
  defaultPrefix?: string | undefined;
250
+ attachments?: {
251
+ url: string;
252
+ title?: string | undefined;
253
+ }[] | null | undefined;
186
254
  }>>;
187
255
  }, "strip", z.ZodTypeAny, {
188
256
  version?: number | undefined;
@@ -192,6 +260,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
192
260
  id?: string | undefined;
193
261
  title?: string | undefined;
194
262
  defaultPrefix?: string | undefined;
263
+ attachments?: {
264
+ url: string;
265
+ title?: string | undefined;
266
+ }[] | undefined;
195
267
  } | undefined;
196
268
  }, {
197
269
  version?: number | undefined;
@@ -201,6 +273,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
201
273
  id?: string | undefined;
202
274
  title?: string | undefined;
203
275
  defaultPrefix?: string | undefined;
276
+ attachments?: {
277
+ url: string;
278
+ title?: string | undefined;
279
+ }[] | null | undefined;
204
280
  } | undefined;
205
281
  }>;
206
282
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
@@ -212,14 +288,44 @@ export declare const RequirementsFileSchema: z.ZodObject<{
212
288
  id: z.ZodOptional<z.ZodString>;
213
289
  title: z.ZodOptional<z.ZodString>;
214
290
  defaultPrefix: z.ZodOptional<z.ZodString>;
291
+ attachments: z.ZodEffects<z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
292
+ url: z.ZodString;
293
+ title: z.ZodOptional<z.ZodString>;
294
+ }, "strip", z.ZodTypeAny, {
295
+ url: string;
296
+ title?: string | undefined;
297
+ }, {
298
+ url: string;
299
+ title?: string | undefined;
300
+ }>, "many">>>, {
301
+ url: string;
302
+ title?: string | undefined;
303
+ }[] | undefined, {
304
+ url: string;
305
+ title?: string | undefined;
306
+ }[] | null | undefined>, {
307
+ url: string;
308
+ title?: string | undefined;
309
+ }[] | undefined, {
310
+ url: string;
311
+ title?: string | undefined;
312
+ }[] | null | undefined>;
215
313
  }, "strip", z.ZodTypeAny, {
216
314
  id?: string | undefined;
217
315
  title?: string | undefined;
218
316
  defaultPrefix?: string | undefined;
317
+ attachments?: {
318
+ url: string;
319
+ title?: string | undefined;
320
+ }[] | undefined;
219
321
  }, {
220
322
  id?: string | undefined;
221
323
  title?: string | undefined;
222
324
  defaultPrefix?: string | undefined;
325
+ attachments?: {
326
+ url: string;
327
+ title?: string | undefined;
328
+ }[] | null | undefined;
223
329
  }>>;
224
330
  }, "strip", z.ZodTypeAny, {
225
331
  version?: number | undefined;
@@ -229,6 +335,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
229
335
  id?: string | undefined;
230
336
  title?: string | undefined;
231
337
  defaultPrefix?: string | undefined;
338
+ attachments?: {
339
+ url: string;
340
+ title?: string | undefined;
341
+ }[] | undefined;
232
342
  } | undefined;
233
343
  }, {
234
344
  version?: number | undefined;
@@ -238,6 +348,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
238
348
  id?: string | undefined;
239
349
  title?: string | undefined;
240
350
  defaultPrefix?: string | undefined;
351
+ attachments?: {
352
+ url: string;
353
+ title?: string | undefined;
354
+ }[] | null | undefined;
241
355
  } | undefined;
242
356
  }>;
243
357
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
@@ -249,14 +363,44 @@ export declare const RequirementsFileSchema: z.ZodObject<{
249
363
  id: z.ZodOptional<z.ZodString>;
250
364
  title: z.ZodOptional<z.ZodString>;
251
365
  defaultPrefix: z.ZodOptional<z.ZodString>;
366
+ attachments: z.ZodEffects<z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
367
+ url: z.ZodString;
368
+ title: z.ZodOptional<z.ZodString>;
369
+ }, "strip", z.ZodTypeAny, {
370
+ url: string;
371
+ title?: string | undefined;
372
+ }, {
373
+ url: string;
374
+ title?: string | undefined;
375
+ }>, "many">>>, {
376
+ url: string;
377
+ title?: string | undefined;
378
+ }[] | undefined, {
379
+ url: string;
380
+ title?: string | undefined;
381
+ }[] | null | undefined>, {
382
+ url: string;
383
+ title?: string | undefined;
384
+ }[] | undefined, {
385
+ url: string;
386
+ title?: string | undefined;
387
+ }[] | null | undefined>;
252
388
  }, "strip", z.ZodTypeAny, {
253
389
  id?: string | undefined;
254
390
  title?: string | undefined;
255
391
  defaultPrefix?: string | undefined;
392
+ attachments?: {
393
+ url: string;
394
+ title?: string | undefined;
395
+ }[] | undefined;
256
396
  }, {
257
397
  id?: string | undefined;
258
398
  title?: string | undefined;
259
399
  defaultPrefix?: string | undefined;
400
+ attachments?: {
401
+ url: string;
402
+ title?: string | undefined;
403
+ }[] | null | undefined;
260
404
  }>>;
261
405
  }, "strip", z.ZodTypeAny, {
262
406
  version?: number | undefined;
@@ -266,6 +410,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
266
410
  id?: string | undefined;
267
411
  title?: string | undefined;
268
412
  defaultPrefix?: string | undefined;
413
+ attachments?: {
414
+ url: string;
415
+ title?: string | undefined;
416
+ }[] | undefined;
269
417
  } | undefined;
270
418
  }, {
271
419
  version?: number | undefined;
@@ -275,6 +423,10 @@ export declare const RequirementsFileSchema: z.ZodObject<{
275
423
  id?: string | undefined;
276
424
  title?: string | undefined;
277
425
  defaultPrefix?: string | undefined;
426
+ attachments?: {
427
+ url: string;
428
+ title?: string | undefined;
429
+ }[] | null | undefined;
278
430
  } | undefined;
279
431
  }>;
280
432
  }, z.ZodTypeAny, "passthrough">>;
@@ -147,6 +147,24 @@ export const MetadataSchema = z.object({
147
147
  id: z.string().optional(), // Cloud document link - filled by push when creating
148
148
  title: z.string().optional(), // Legacy fallback — the body's leading H1 is the title
149
149
  defaultPrefix: z.string().optional(), // Editor hint for new requirement keys
150
+ // ATTACH-10: links attached to the document (a Miro board, a reference
151
+ // page). Round-trips through sync — the cloud stores them as document
152
+ // metadata, the file carries them here.
153
+ // A bare `attachments:` key parses as YAML null and means the same as
154
+ // an explicit empty list — "this document has none" — which is the
155
+ // natural hand-edit for removing every entry. Absent stays undefined
156
+ // (the file takes no position; see sync/attachments.ts).
157
+ attachments: z
158
+ .array(z.object({
159
+ url: z.string(),
160
+ title: z.string().optional(),
161
+ }))
162
+ .nullable()
163
+ .optional()
164
+ .transform((a) => (a === null ? [] : a))
165
+ .refine((a) => a === undefined || new Set(a.map((x) => x.url)).size === a.length, {
166
+ message: "attachments list the same url more than once — remove the duplicate entry",
167
+ }),
150
168
  })
151
169
  .optional(),
152
170
  });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Attachment resolution, outside the document verdict (ATTACH-10.2).
3
+ *
4
+ * Attachments are a small named-link set, not additive body content: three
5
+ * review rounds showed that routing them through the body's verdict matrix
6
+ * broke a different cell each round — a name difference freezing the whole
7
+ * document, planning a destructive body publish, or --repo-wins wiping
8
+ * attachments a pre-feature file never mentioned. So they resolve here, by
9
+ * their own rule, and the body syncs as if they didn't exist.
10
+ *
11
+ * The rule, per URL:
12
+ * - Additive modes union the two sides. Removals don't propagate (as
13
+ * everywhere in additive sync); a contributes mode only changes the
14
+ * receiving side.
15
+ * - Names: a name beats no name; when both sides name the same URL
16
+ * differently, the REPO wins — it is the hand-authored side, and
17
+ * ATTACH-10.1 promises frontmatter edits propagate (decided 2026-08-20).
18
+ * - Authority modes make that side's list the outcome — except that a file
19
+ * with NO attachments key has no opinion: absence cannot distinguish "the
20
+ * user removed everything" from "this file predates attachments", so it
21
+ * never clears anything, even under --repo-wins. An explicit
22
+ * `attachments: []` (or a bare `attachments:` key) is how a file says
23
+ * "none".
24
+ */
25
+ import { attachmentListsEqual } from "../schema/attachment-lists.js";
26
+ import type { SyncMode } from "./plan.js";
27
+ import type { Attachment } from "./types.js";
28
+ export { attachmentListsEqual };
29
+ export interface AttachmentsResolution {
30
+ /** The list the cloud should end with. */
31
+ cloudFinal: Attachment[];
32
+ /** The list the file should end with (undefined = leave the file alone). */
33
+ localFinal: Attachment[] | undefined;
34
+ /** The cloud's list differs from cloudFinal — a push is needed. */
35
+ pushNeeded: boolean;
36
+ /** The file's effective list differs from localFinal — a write is needed. */
37
+ writeNeeded: boolean;
38
+ }
39
+ /**
40
+ * Does this document need attachment work its body action won't do? The
41
+ * sync command's "already in sync" gate asks this — the resolver, not the
42
+ * raw `differ` flag, because four of five modes deliberately leave one side
43
+ * alone (a no-key file under repo_contributes is a permanent no-op, not
44
+ * pending work).
45
+ */
46
+ export declare function attachmentSyncNeeded(attachments: {
47
+ local: Attachment[] | undefined;
48
+ cloud: Attachment[];
49
+ } | undefined, mode: SyncMode): boolean;
50
+ /**
51
+ * The cloud attachments a sync under this mode removes. Removals ride
52
+ * authority ungated — like requirement blocks dropped from a file's body,
53
+ * and unlike whole documents (SYNC-MODE-4 gates only those); the docs
54
+ * promise attachments never hold up the rest of the spec, and a link is
55
+ * re-pasteable. This names them for the post-run report (ATTACH-10.3.1.0).
56
+ * Only authority can remove (union never shrinks), so this is empty
57
+ * outside --repo-wins.
58
+ */
59
+ export declare function cloudAttachmentRemovals(attachments: {
60
+ local: Attachment[] | undefined;
61
+ cloud: Attachment[];
62
+ } | undefined, mode: SyncMode): Attachment[];
63
+ export declare function resolveAttachments(local: Attachment[] | undefined, cloud: Attachment[] | undefined, mode: SyncMode): AttachmentsResolution;
64
+ //# sourceMappingURL=attachments.d.ts.map