@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.
@@ -0,0 +1,149 @@
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
+ export { attachmentListsEqual };
27
+ /** One entry per URL — legacy cloud rows can hold duplicates from before
28
+ * the validator, and writing them into frontmatter produces a file our own
29
+ * parser rejects (invalid_file, requirements and all, in every mode). The
30
+ * FIRST TITLED occurrence wins the name: keeping strictly the first entry
31
+ * dropped a later name, contradicting "a name beats no name" below. */
32
+ function dedupeByUrl(list) {
33
+ const byUrl = new Map();
34
+ for (const a of list) {
35
+ const kept = byUrl.get(a.url);
36
+ if (!kept || (kept.title === undefined && a.title !== undefined)) {
37
+ byUrl.set(a.url, kept ? { url: a.url, title: a.title } : a);
38
+ }
39
+ }
40
+ return [...byUrl.values()];
41
+ }
42
+ /**
43
+ * Union with the name rule: cloud order first (stable for the web), repo-only
44
+ * URLs appended in file order; per shared URL, a name beats no name and the
45
+ * repo's name wins a tie.
46
+ */
47
+ function merge(local, cloud) {
48
+ const localByUrl = new Map(local.map((a) => [a.url, a]));
49
+ const merged = cloud.map((c) => {
50
+ const l = localByUrl.get(c.url);
51
+ const title = l?.title ?? c.title;
52
+ return title === undefined ? { url: c.url } : { url: c.url, title };
53
+ });
54
+ const cloudUrls = new Set(cloud.map((a) => a.url));
55
+ for (const l of local) {
56
+ if (!cloudUrls.has(l.url))
57
+ merged.push(l);
58
+ }
59
+ return merged;
60
+ }
61
+ /**
62
+ * Does this document need attachment work its body action won't do? The
63
+ * sync command's "already in sync" gate asks this — the resolver, not the
64
+ * raw `differ` flag, because four of five modes deliberately leave one side
65
+ * alone (a no-key file under repo_contributes is a permanent no-op, not
66
+ * pending work).
67
+ */
68
+ export function attachmentSyncNeeded(attachments, mode) {
69
+ if (!attachments)
70
+ return false;
71
+ const res = resolveAttachments(attachments.local, attachments.cloud, mode);
72
+ return res.pushNeeded || res.writeNeeded;
73
+ }
74
+ /**
75
+ * The cloud attachments a sync under this mode removes. Removals ride
76
+ * authority ungated — like requirement blocks dropped from a file's body,
77
+ * and unlike whole documents (SYNC-MODE-4 gates only those); the docs
78
+ * promise attachments never hold up the rest of the spec, and a link is
79
+ * re-pasteable. This names them for the post-run report (ATTACH-10.3.1.0).
80
+ * Only authority can remove (union never shrinks), so this is empty
81
+ * outside --repo-wins.
82
+ */
83
+ export function cloudAttachmentRemovals(attachments, mode) {
84
+ if (!attachments)
85
+ return [];
86
+ const res = resolveAttachments(attachments.local, attachments.cloud, mode);
87
+ return attachments.cloud.filter((c) => !res.cloudFinal.some((f) => f.url === c.url));
88
+ }
89
+ export function resolveAttachments(local, cloud, mode) {
90
+ // The cloud's absence IS "none" — the web shows no attachments. Only the
91
+ // file has the third state. RESOLUTION runs on deduped views (every
92
+ // output must be writable to frontmatter our own parser accepts), but the
93
+ // push/write decisions below compare against the RAW lists — that is what
94
+ // makes a duplicated legacy row repairable: its deduped resolution
95
+ // differs from the raw row, so the corrected list gets pushed.
96
+ const rawCloud = cloud ?? [];
97
+ const rawLocal = local ?? [];
98
+ const effCloud = dedupeByUrl(rawCloud);
99
+ const effLocal = dedupeByUrl(rawLocal);
100
+ let cloudFinal;
101
+ let localFinal;
102
+ switch (mode) {
103
+ case "repo_wins":
104
+ // Normalized like merge's output: an entry read from frontmatter can
105
+ // carry an explicit `title: undefined` key, which only this branch
106
+ // would otherwise pass through to the wire.
107
+ cloudFinal =
108
+ local === undefined
109
+ ? effCloud
110
+ : local.map((a) => a.title === undefined
111
+ ? { url: a.url }
112
+ : { url: a.url, title: a.title });
113
+ localFinal = undefined; // the file is the authority; leave it alone
114
+ break;
115
+ case "cloud_wins":
116
+ cloudFinal = effCloud;
117
+ localFinal = effCloud;
118
+ break;
119
+ case "repo_contributes":
120
+ cloudFinal = merge(effLocal, effCloud);
121
+ localFinal = undefined;
122
+ break;
123
+ case "cloud_contributes":
124
+ cloudFinal = effCloud;
125
+ localFinal = merge(effLocal, effCloud);
126
+ break;
127
+ default: {
128
+ // bare: both sides converge on the union.
129
+ const merged = merge(effLocal, effCloud);
130
+ cloudFinal = merged;
131
+ localFinal = merged;
132
+ }
133
+ }
134
+ // The raw-vs-resolved comparison is what makes a duplicated legacy row
135
+ // repairable — but ONLY modes that legitimately write the cloud may run
136
+ // that repair: under cloud authority the mode's promise is that the repo
137
+ // never overwrites the cloud, and firing a push there rewrote the row
138
+ // unconsented (round 9). Cloud-authority modes compare deduped-to-deduped,
139
+ // which is stable: the duplicated row shows in diff and is cleaned up the
140
+ // next time any pushing mode runs.
141
+ const cloudWriting = mode === "bare" || mode === "repo_wins" || mode === "repo_contributes";
142
+ return {
143
+ cloudFinal,
144
+ localFinal,
145
+ pushNeeded: !attachmentListsEqual(cloudWriting ? rawCloud : effCloud, cloudFinal),
146
+ writeNeeded: localFinal !== undefined && !attachmentListsEqual(rawLocal, localFinal),
147
+ };
148
+ }
149
+ //# sourceMappingURL=attachments.js.map
@@ -3,6 +3,7 @@
3
3
  * reads no disk and makes no network calls. `dotreq diff` renders its result,
4
4
  * `dotreq sync` acts on it, CI asserts on it.
5
5
  */
6
+ import { attachmentListsEqual } from "./attachments.js";
6
7
  import { normalizeProse, requirementUnitMap, segmentBody, } from "./segment.js";
7
8
  /** Whitespace-only normalization for the fast-path body equality check. */
8
9
  function normalizeBody(body) {
@@ -184,6 +185,81 @@ function proseLabel(text) {
184
185
  ? `${firstLine.slice(0, 40)}…`
185
186
  : firstLine || "(prose)";
186
187
  }
188
+ function attachmentLabel(url) {
189
+ return url.length > 60 ? `${url.slice(0, 60)}…` : url;
190
+ }
191
+ /**
192
+ * Describe attachment differences for `dotreq diff` (ATTACH-10.2). Display
193
+ * only: attachments never touch the document's verdict — they resolve by
194
+ * their own rule in sync/attachments.ts. The directional verdicts here name
195
+ * which side holds the difference, for the reader.
196
+ */
197
+ function describeAttachments(local, cloud) {
198
+ const details = [];
199
+ const localByUrl = new Map((local ?? []).map((a) => [a.url, a]));
200
+ const cloudByUrl = new Map((cloud ?? []).map((a) => [a.url, a]));
201
+ for (const [url, a] of localByUrl) {
202
+ const c = cloudByUrl.get(url);
203
+ if (!c) {
204
+ details.push({
205
+ unit: attachmentLabel(url),
206
+ verdict: "additions_in_repo",
207
+ note: "attachment",
208
+ });
209
+ }
210
+ else if ((a.title ?? "") !== (c.title ?? "")) {
211
+ details.push({
212
+ unit: attachmentLabel(url),
213
+ verdict: (a.title ?? "") === "" ? "additions_in_cloud" : "additions_in_repo",
214
+ note: "attachment name",
215
+ });
216
+ }
217
+ }
218
+ for (const url of cloudByUrl.keys()) {
219
+ if (!localByUrl.has(url)) {
220
+ details.push({
221
+ unit: attachmentLabel(url),
222
+ verdict: "additions_in_cloud",
223
+ note: "attachment",
224
+ });
225
+ }
226
+ }
227
+ // Duplicate entries collapse in the URL-keyed maps above, so a
228
+ // duplicated row would flag `differ` with no detail line beneath it —
229
+ // a not-in-sync header over nothing. Count per URL and say so.
230
+ const countBy = (list) => {
231
+ const counts = new Map();
232
+ for (const a of list ?? [])
233
+ counts.set(a.url, (counts.get(a.url) ?? 0) + 1);
234
+ return counts;
235
+ };
236
+ const localCounts = countBy(local);
237
+ const cloudCounts = countBy(cloud);
238
+ for (const [url, n] of cloudCounts) {
239
+ // Both-sides URLs only: a cloud-only duplicated URL already gets the
240
+ // first loop's addition line, and a second line with the same label
241
+ // double-reports one link (round 10). No local twin — the frontmatter
242
+ // schema rejects duplicates, so such a file is invalid_file before it
243
+ // reaches this comparison.
244
+ const localN = localCounts.get(url);
245
+ if (n > 1 && localN !== undefined && n !== localN) {
246
+ details.push({
247
+ unit: attachmentLabel(url),
248
+ verdict: "additions_in_cloud",
249
+ note: "duplicated entry",
250
+ });
251
+ }
252
+ }
253
+ // `differ` is THE predicate every consumer gates on (the sync command's
254
+ // gate resolves it further by mode; the executor's residual path trusts
255
+ // it) — so it is computed by the same set-equality the resolver uses,
256
+ // not from these display details: the URL-keyed maps above collapse
257
+ // duplicate entries that the length-aware comparison still sees.
258
+ return {
259
+ differ: !attachmentListsEqual(local ?? [], cloud ?? []),
260
+ details,
261
+ };
262
+ }
187
263
  /** Compare one document across surfaces. */
188
264
  function compareDocument(local, cloud) {
189
265
  const base = {
@@ -194,9 +270,17 @@ function compareDocument(local, cloud) {
194
270
  };
195
271
  const diverges = attributesDiverge(local, cloud);
196
272
  const body = local.body ?? "";
273
+ const attach = describeAttachments(local.attachments, cloud.attachments);
274
+ const attachments = {
275
+ local: local.attachments,
276
+ cloud: cloud.attachments ?? [],
277
+ differ: attach.differ,
278
+ details: attach.details,
279
+ };
197
280
  // Fast path: whitespace-identical bodies with matching attributes.
281
+ // Attachments deliberately don't gate this — they resolve on their own.
198
282
  if (!diverges && normalizeBody(body) === normalizeBody(cloud.body)) {
199
- return { ...base, verdict: "in_sync" };
283
+ return { ...base, verdict: "in_sync", attachments };
200
284
  }
201
285
  // SYNC-FAIL-4.2: cloud bodies reach the snapshot as stored markdown, never
202
286
  // re-parsed, so a document saved before today's grammar can still be
@@ -211,21 +295,21 @@ function compareDocument(local, cloud) {
211
295
  classified = classifyBodies(body, cloud.body);
212
296
  }
213
297
  catch {
214
- return { ...base, verdict: "conflict" };
298
+ // `attachments` rides along like every other return: without it the
299
+ // residual sync and verbose diff silently drop this document's links.
300
+ return { ...base, verdict: "conflict", attachments };
215
301
  }
216
302
  const { verdict: bodyVerdict, details } = classified;
217
- let verdict;
218
- if (diverges) {
219
- // A changed single-valued attribute is never additive → conflict.
220
- verdict = "conflict";
221
- }
222
- else {
223
- verdict = bodyVerdict;
224
- }
303
+ // ATTACH-10.2: the verdict is the BODY's verdict. Attachments never move
304
+ // it — a name difference could otherwise freeze the document
305
+ // (skip_conflict) or, worse, plan a destructive body publish over a
306
+ // colleague's unpublished work, from someone naming a link in frontmatter.
307
+ const verdict = diverges ? "conflict" : bodyVerdict;
225
308
  return {
226
309
  ...base,
227
310
  verdict,
228
311
  details: verdict === "in_sync" ? undefined : details,
312
+ attachments,
229
313
  };
230
314
  }
231
315
  /**
@@ -16,6 +16,17 @@ export interface SyncOutcome {
16
16
  cloudDeleted: number;
17
17
  localWritten: number;
18
18
  localDeleted: number;
19
+ /** ATTACH-10.2: attachment-only operations, outside the body actions. */
20
+ attachmentPushes: number;
21
+ attachmentWrites: number;
22
+ /** ATTACH-10.3.1.0: cloud attachments a push removed on the file's
23
+ * authority. Recorded only after the push succeeded — the report's trace
24
+ * must never claim a removal a refused or failed push left in place. */
25
+ attachmentRemovals: Array<{
26
+ name: string;
27
+ url: string;
28
+ title?: string;
29
+ }>;
19
30
  conflicts: DocumentComparison[];
20
31
  invalid: DocumentComparison[];
21
32
  errors: Array<{
@@ -14,6 +14,7 @@ import { getConvexUrl } from "../config.js";
14
14
  import { api } from "../convex.js";
15
15
  import { parseFilesForPushIndividually, WEB_APP_URL } from "../push/core.js";
16
16
  import { composeMarkdownWithTitle, splitLeadingH1, writeRequirementsFile, } from "../schema/index.js";
17
+ import { cloudAttachmentRemovals, resolveAttachments } from "./attachments.js";
17
18
  import { resolveLocalPath } from "./local-files.js";
18
19
  import { publishToCloud } from "./publish.js";
19
20
  function emptyOutcome() {
@@ -23,6 +24,9 @@ function emptyOutcome() {
23
24
  cloudDeleted: 0,
24
25
  localWritten: 0,
25
26
  localDeleted: 0,
27
+ attachmentPushes: 0,
28
+ attachmentWrites: 0,
29
+ attachmentRemovals: [],
26
30
  conflicts: [],
27
31
  invalid: [],
28
32
  errors: [],
@@ -59,7 +63,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
59
63
  outcome.invalid.push(doc);
60
64
  break;
61
65
  case "cloud_write":
62
- await cloudWrite(auth, doc, cloudById, outcome);
66
+ await cloudWrite(auth, doc, cloudById, outcome, mode);
63
67
  break;
64
68
  case "cloud_delete":
65
69
  await cloudDelete(client, auth, doc, outcome);
@@ -71,6 +75,12 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
71
75
  localDelete(doc, outcome);
72
76
  break;
73
77
  }
78
+ // ATTACH-10.2: attachments resolve outside the body's verdict, so a
79
+ // body action may not have carried them — cover the residue. cloud_write
80
+ // pushed cloudFinal and wrote back localFinal; local_write wrote
81
+ // localFinal; deletes moot the question. Everything else (skip,
82
+ // skip_conflict, and the un-pushed side of local_write) lands here.
83
+ await syncResidualAttachments(client, auth, doc, action, outcome, mode);
74
84
  }
75
85
  catch (err) {
76
86
  outcome.errors.push({ name, error: errorDisplayMessage(err) });
@@ -78,6 +88,79 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
78
88
  }
79
89
  return outcome;
80
90
  }
91
+ /**
92
+ * Attachment-only operations for whatever the body action didn't cover.
93
+ * The push goes through setAttachmentsForSync — a metadata patch, never the
94
+ * publish path, so naming a link cannot displace anyone's working document.
95
+ */
96
+ async function syncResidualAttachments(client, auth, doc, action, outcome, mode) {
97
+ if (!doc.attachments?.differ || !doc.documentId)
98
+ return;
99
+ // A conflicted document is reported as left alone, with a resolution hint
100
+ // — rewriting its frontmatter or its cloud list would falsify that report
101
+ // for someone mid-resolution. Deletes and invalid files moot the question.
102
+ if (action === "cloud_delete" ||
103
+ action === "local_delete" ||
104
+ action === "invalid" ||
105
+ action === "skip_conflict") {
106
+ return;
107
+ }
108
+ const res = resolveAttachments(doc.attachments.local, doc.attachments.cloud, mode);
109
+ if (res.pushNeeded && action !== "cloud_write") {
110
+ if (!auth.projectSlug) {
111
+ throw new Error("Cannot sync attachments without project credentials.");
112
+ }
113
+ await client.mutation(api.documents.mutations.setAttachmentsForSync, {
114
+ projectAuth: {
115
+ projectSlug: auth.projectSlug,
116
+ projectSecret: auth.projectSecret,
117
+ },
118
+ target: { type: "project", slug: auth.projectSlug },
119
+ documentId: doc.documentId,
120
+ attachments: res.cloudFinal,
121
+ // Compare-and-set: the resolution was computed from the snapshot's
122
+ // cloud list; if the cloud moved since (a paste in the web app while
123
+ // this run sat at a prompt), replaying our list would delete it. The
124
+ // mutation refuses instead, and the next run resolves fresh.
125
+ expectAttachments: doc.attachments.cloud,
126
+ });
127
+ outcome.attachmentPushes++;
128
+ for (const removed of cloudAttachmentRemovals(doc.attachments, mode)) {
129
+ outcome.attachmentRemovals.push({
130
+ name: doc.title,
131
+ url: removed.url,
132
+ title: removed.title,
133
+ });
134
+ }
135
+ }
136
+ if (res.writeNeeded &&
137
+ res.localFinal !== undefined &&
138
+ action !== "local_write" &&
139
+ action !== "cloud_write" &&
140
+ doc.filePath) {
141
+ const { parsedFiles } = parseFilesForPushIndividually([doc.filePath]);
142
+ const parsed = parsedFiles[0];
143
+ if (!parsed)
144
+ return;
145
+ // The writer drops the legacy frontmatter `document.title`
146
+ // (SYNC-TITLE-1.2) — same compensation cloudWrite makes: an H1-less
147
+ // legacy body gets its title materialized as the leading H1, or an
148
+ // attachment-only write would leave the file untitled and the next
149
+ // compare would flag a phantom title conflict.
150
+ let body = parsed.markdownContent;
151
+ const legacyTitle = parsed.metadata.document?.title;
152
+ if (legacyTitle && splitLeadingH1(body).title === null) {
153
+ body = composeMarkdownWithTitle(legacyTitle, body);
154
+ }
155
+ writeRequirementsFile(doc.filePath, {
156
+ documentId: doc.documentId,
157
+ defaultPrefix: "keep",
158
+ attachments: { set: res.localFinal },
159
+ version: (parsed.metadata.version ?? 0) + 1,
160
+ }, body);
161
+ outcome.attachmentWrites++;
162
+ }
163
+ }
81
164
  /**
82
165
  * Servers throw ConvexError({kind, message}) for expected failures; production
83
166
  * redacts the Error message to "Server Error" but preserves error.data.
@@ -89,7 +172,7 @@ function errorDisplayMessage(err) {
89
172
  return data.message;
90
173
  return err instanceof Error ? err.message : String(err);
91
174
  }
92
- async function cloudWrite(auth, doc, cloudById, outcome) {
175
+ async function cloudWrite(auth, doc, cloudById, outcome, mode) {
93
176
  if (!auth.projectSlug || !doc.filePath) {
94
177
  throw new Error("Cannot write to the cloud without project credentials.");
95
178
  }
@@ -109,6 +192,7 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
109
192
  if (meta?.title && splitLeadingH1(parsed.markdownContent).title === null) {
110
193
  parsed.markdownContent = composeMarkdownWithTitle(meta.title, parsed.markdownContent);
111
194
  }
195
+ const attachmentsRes = resolveAttachments(meta?.attachments, meta?.id ? cloudById.get(meta.id)?.attachments : undefined, mode);
112
196
  // SYNC-CLI-EDIT-2: the push goes to the web app, which writes it into the
113
197
  // working document Quinn may have open and publishes a copy printed from it.
114
198
  // Convex cannot do that job — the conversion needs the BlockNote schema.
@@ -124,9 +208,43 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
124
208
  title: meta?.title,
125
209
  markdownContent: parsed.markdownContent,
126
210
  defaultPrefix: meta?.defaultPrefix,
211
+ // ATTACH-10.2: the push carries the RESOLVED list (sync/attachments.ts)
212
+ // — union in additive modes, authority as declared, a file with no
213
+ // attachments key having no opinion — or nothing at all when the
214
+ // cloud's list already is the resolution.
215
+ attachments: attachmentsRes.pushNeeded
216
+ ? attachmentsRes.cloudFinal
217
+ : undefined,
218
+ // Compare-and-set whenever the push replaces the list: the cloud copy
219
+ // the resolution was computed against. A link pasted mid-sync makes the
220
+ // publish refuse (dry-run first, so nothing is destroyed) instead of
221
+ // silently replaying the stale list over it.
222
+ expectAttachments: attachmentsRes.pushNeeded
223
+ ? meta?.id
224
+ ? (cloudById.get(meta.id)?.attachments ?? [])
225
+ : []
226
+ : undefined,
127
227
  runMarker: parsed.metadata.ctsRun,
128
228
  });
129
229
  const documentId = saveResult.documentId;
230
+ // ATTACH-10.3.1.0: the publish above is the push that removes cloud
231
+ // attachments under repo authority — trace them now that it has landed.
232
+ // Named by file, like every other per-document line here: doc.title is
233
+ // pinned to the OLD cloud title, so a push that renames the H1 and drops
234
+ // a link in one edit would print the removal against a title that no
235
+ // longer exists (round-2 release review).
236
+ if (attachmentsRes.pushNeeded) {
237
+ const cloudBefore = meta?.id
238
+ ? (cloudById.get(meta.id)?.attachments ?? [])
239
+ : [];
240
+ for (const removed of cloudAttachmentRemovals({ local: meta?.attachments, cloud: cloudBefore }, mode)) {
241
+ outcome.attachmentRemovals.push({
242
+ name: path.basename(doc.filePath),
243
+ url: removed.url,
244
+ title: removed.title,
245
+ });
246
+ }
247
+ }
130
248
  // The server accepted the publish and told us something after it did not
131
249
  // finish. Reported rather than dropped: the push DID land, so this is not a
132
250
  // failure, but "synced" on its own would claim more than happened.
@@ -217,6 +335,11 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
217
335
  defaultPrefix: meta?.defaultPrefix
218
336
  ? { set: meta.defaultPrefix }
219
337
  : "keep",
338
+ // The file adopts its side of the resolution — cloud-held names it
339
+ // lacked, for one (ATTACH-10.2).
340
+ attachments: attachmentsRes.writeNeeded && attachmentsRes.localFinal !== undefined
341
+ ? { set: attachmentsRes.localFinal }
342
+ : "keep",
220
343
  version: (parsed.metadata.version ?? 0) + 1,
221
344
  }, contentToWriteBack);
222
345
  // Counted only once the rewrite actually happened: reporting it before a
@@ -265,9 +388,19 @@ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode) {
265
388
  : mode === "cloud_wins"
266
389
  ? "clear"
267
390
  : "keep";
391
+ // ATTACH-10.0/10.2: the file adopts its side of the attachment resolution
392
+ // — cloud's list plus the file's own additions in additive modes, the
393
+ // cloud's exactly under cloud authority. An explicit empty list is written
394
+ // as `attachments: []` (present key = an opinion); no write happens when
395
+ // the file already agrees.
396
+ const attachRes = resolveAttachments(doc.attachments?.local, cloudDoc.attachments, mode);
397
+ const attachments = attachRes.writeNeeded && attachRes.localFinal !== undefined
398
+ ? { set: attachRes.localFinal }
399
+ : "keep";
268
400
  const changed = writeRequirementsFile(filePath, {
269
401
  documentId: cloudDoc.documentId,
270
402
  defaultPrefix,
403
+ attachments,
271
404
  version: cloudDoc.version,
272
405
  }, cloudDoc.body);
273
406
  if (changed)
@@ -21,6 +21,16 @@ export interface PublishParams {
21
21
  /** Legacy frontmatter title, used only as the server's H1-less fallback. */
22
22
  title?: string;
23
23
  defaultPrefix?: string;
24
+ /** document.attachments from the file's frontmatter (ATTACH-10.1). */
25
+ attachments?: Array<{
26
+ url: string;
27
+ title?: string;
28
+ }>;
29
+ /** Compare-and-set: the cloud attachments the sync read them against. */
30
+ expectAttachments?: Array<{
31
+ url: string;
32
+ title?: string;
33
+ }>;
24
34
  runMarker?: string;
25
35
  }
26
36
  /**
@@ -33,6 +33,13 @@ export function resolutionHint(doc) {
33
33
  }
34
34
  /** One line per document: glyph, name, verdict. */
35
35
  export function formatVerdictLine(doc) {
36
+ // ATTACH-10.2: attachments resolve outside the verdict, so a document can
37
+ // be in sync while its attachments differ — say so rather than reporting
38
+ // "in sync" over a real difference. (Diff is mode-independent, like the
39
+ // body verdicts: whether a sync would act on it depends on the mode.)
40
+ if (doc.verdict === "in_sync" && doc.attachments?.differ) {
41
+ return ` ~ ${displayName(doc).padEnd(40)} attachments differ`;
42
+ }
36
43
  return ` ${GLYPH[doc.verdict]} ${displayName(doc).padEnd(40)} ${verdictLabel(doc.verdict)}`;
37
44
  }
38
45
  /** Verbose, one-level-unfolded detail for a document (DIFF-2). */
@@ -32,6 +32,7 @@ export function readLocalDocument(filePath) {
32
32
  let documentId;
33
33
  let title;
34
34
  let defaultPrefix;
35
+ let attachments;
35
36
  let frontmatterError;
36
37
  const split = splitFrontmatter(raw);
37
38
  if (split) {
@@ -56,6 +57,24 @@ export function readLocalDocument(filePath) {
56
57
  title = d.title;
57
58
  if (typeof d.defaultPrefix === "string")
58
59
  defaultPrefix = d.defaultPrefix;
60
+ // ATTACH-10.1: attachments from frontmatter. Shape-filtered here for
61
+ // the same reason as the fields above; a malformed entry is caught by
62
+ // validateMetadata below and flags the file rather than half-syncing.
63
+ // A present-but-empty key (`attachments:` or `attachments: []`) is an
64
+ // explicit "none" — the tri-state the resolver needs: only a file
65
+ // with NO key has no opinion (sync/attachments.ts).
66
+ if (d.attachments === null) {
67
+ attachments = [];
68
+ }
69
+ else if (Array.isArray(d.attachments)) {
70
+ attachments = d.attachments
71
+ .filter((a) => !!a && typeof a === "object" && !Array.isArray(a))
72
+ .filter((a) => typeof a.url === "string")
73
+ .map((a) => ({
74
+ url: a.url,
75
+ title: typeof a.title === "string" ? a.title : undefined,
76
+ }));
77
+ }
59
78
  }
60
79
  }
61
80
  if (rawMeta !== undefined && !frontmatterError) {
@@ -83,7 +102,14 @@ export function readLocalDocument(filePath) {
83
102
  // frontmatter title stands only when the body has none (SYNC-TITLE-1.1);
84
103
  // with neither, the document is untitled (SYNC-TITLE-1.4).
85
104
  const derivedTitle = splitLeadingH1(body).title ?? title;
86
- return { filePath, documentId, title: derivedTitle, defaultPrefix, body };
105
+ return {
106
+ filePath,
107
+ documentId,
108
+ title: derivedTitle,
109
+ defaultPrefix,
110
+ attachments,
111
+ body,
112
+ };
87
113
  }
88
114
  /**
89
115
  * Build the local snapshot from the workspace, or from an explicit set of file
@@ -113,6 +139,7 @@ export async function acquireCloudSnapshot(auth) {
113
139
  documentId: d.documentId,
114
140
  title: d.title,
115
141
  defaultPrefix: d.defaultPrefix,
142
+ attachments: d.attachments,
116
143
  body: d.markdownContent,
117
144
  version: d.version,
118
145
  updatedAt: d.updatedAt,
@@ -11,6 +11,11 @@
11
11
  export type Verdict = "in_sync" | "additions_in_repo" | "additions_in_cloud" | "conflict" | "only_in_repo" | "only_in_cloud" | "invalid_file";
12
12
  /** The user-facing spelling of a verdict — same words, spaced. */
13
13
  export declare function verdictLabel(verdict: Verdict): string;
14
+ /** One attached link (ATTACH-10): document metadata that syncs both ways. */
15
+ export interface Attachment {
16
+ url: string;
17
+ title?: string;
18
+ }
14
19
  /**
15
20
  * One document as it exists locally: a `.requirements.md` file. When the file
16
21
  * cannot be parsed, `parseError` is set and the other content fields are absent
@@ -22,6 +27,8 @@ export interface LocalDocument {
22
27
  documentId?: string;
23
28
  title?: string;
24
29
  defaultPrefix?: string;
30
+ /** document.attachments from frontmatter (ATTACH-10). */
31
+ attachments?: Attachment[];
25
32
  /** Frontmatter-stripped markdown body (the comparison unit, SYNC-ARCH-1). */
26
33
  body?: string;
27
34
  parseError?: string;
@@ -33,6 +40,8 @@ export interface CloudDocument {
33
40
  documentId: string;
34
41
  title: string;
35
42
  defaultPrefix?: string;
43
+ /** The document's attached links (ATTACH-10). */
44
+ attachments?: Attachment[];
36
45
  /** Published markdownContent (SYNC-ARCH-1). */
37
46
  body: string;
38
47
  version: number;
@@ -75,6 +84,20 @@ export interface DocumentComparison {
75
84
  error?: string;
76
85
  /** Unit-level breakdown, populated for verbose diff of non-in-sync documents. */
77
86
  details?: UnitDetail[];
87
+ /**
88
+ * ATTACH-10.2: attachments never contribute to `verdict` — they resolve by
89
+ * their own rule (sync/attachments.ts), so a name difference can neither
90
+ * freeze the body's sync nor plan a body write. This carries what that
91
+ * resolution needs: both sides' lists (`local` undefined = the file has no
92
+ * attachments key = no opinion) and display details for `dotreq diff`.
93
+ */
94
+ attachments?: {
95
+ local: Attachment[] | undefined;
96
+ cloud: Attachment[];
97
+ /** True when the two sides' lists differ (set semantics, names count). */
98
+ differ: boolean;
99
+ details: UnitDetail[];
100
+ };
78
101
  }
79
102
  export interface ComparisonResult {
80
103
  documents: DocumentComparison[];