@popoverai/dotrequirements 0.30.1 → 0.32.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.
@@ -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[];
@@ -57,7 +57,8 @@ test(requirement('REQ-ID.0'), () => { /* test specific criterion */ });
57
57
 
58
58
  **Authoring:**
59
59
  - `dotreq create-requirement-document [path]` - Print the template and style guide
60
- - `dotreq validate [glob]` - Check syntax (works offline)
60
+ - `dotreq greenfield-discovery` - Print the interview protocol for working out what something new should do. Run it when the user is starting something new and has not worked out what it should do — the interview beats drafting requirements from their description
61
+ - `dotreq validate [--file <path>]` - Check syntax (works offline)
61
62
  - `dotreq diff [scope...]` - Show how the repo and the cloud differ (read-only)
62
63
  - `dotreq sync [scope...]` - Reconcile the repo and the cloud (both contribute by default)
63
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popoverai/dotrequirements",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "Requirements as testable, human-readable data — CLI and test harness for spec-driven development with AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "./test": "./dist/harness/index.js",
13
13
  "./schema": "./dist/schema/index.js",
14
14
  "./schema/browser": "./dist/schema/browser.js",
15
- "./style-guide": "./dist/requirements/style-guide.js"
15
+ "./style-guide": "./dist/requirements/style-guide.js",
16
+ "./greenfield": "./dist/requirements/greenfield.js"
16
17
  },
17
18
  "publishConfig": {
18
19
  "access": "public"
@@ -49,7 +50,7 @@
49
50
  "email": "support@dotrequirements.io"
50
51
  },
51
52
  "engines": {
52
- "node": ">=20"
53
+ "node": ">=22"
53
54
  },
54
55
  "dependencies": {
55
56
  "@babel/parser": "^7.28.5",
@@ -71,7 +72,7 @@
71
72
  },
72
73
  "devDependencies": {
73
74
  "@types/babel__traverse": "^7.28.0",
74
- "@types/node": "^20",
75
+ "@types/node": "^22",
75
76
  "@types/uuid": "^11.0.0",
76
77
  "typescript": "^5",
77
78
  "vitest": "^4.1.10"