@popoverai/dotrequirements 0.29.0 → 0.29.2

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 (42) hide show
  1. package/dist/codebase-to-spec/compose.js +0 -4
  2. package/dist/codebase-to-spec/renumber.d.ts +7 -5
  3. package/dist/codebase-to-spec/renumber.js +17 -15
  4. package/dist/commands/report.js +15 -1
  5. package/dist/commands/sync.js +7 -1
  6. package/dist/harness/cache.d.ts +83 -2
  7. package/dist/harness/cache.js +94 -8
  8. package/dist/harness/finalize.js +238 -78
  9. package/dist/harness/reportingStatus.d.ts +44 -0
  10. package/dist/harness/reportingStatus.js +123 -0
  11. package/dist/push/core.d.ts +0 -12
  12. package/dist/push/core.js +8 -58
  13. package/dist/push/index.d.ts +1 -1
  14. package/dist/push/index.js +1 -1
  15. package/dist/requirements/cloud-coverage.js +7 -2
  16. package/dist/schema/browser.d.ts +2 -2
  17. package/dist/schema/browser.js +2 -2
  18. package/dist/schema/builder.d.ts +6 -1
  19. package/dist/schema/builder.js +6 -1
  20. package/dist/schema/conversions.js +12 -3
  21. package/dist/schema/file-writer.d.ts +49 -0
  22. package/dist/schema/file-writer.js +138 -0
  23. package/dist/schema/index.d.ts +5 -2
  24. package/dist/schema/index.js +3 -2
  25. package/dist/schema/parser-core.d.ts +32 -5
  26. package/dist/schema/parser-core.js +136 -31
  27. package/dist/schema/parser.d.ts +2 -1
  28. package/dist/schema/parser.js +1 -1
  29. package/dist/schema/schemas.d.ts +11 -0
  30. package/dist/schema/schemas.js +14 -0
  31. package/dist/sync/compare.js +16 -1
  32. package/dist/sync/execute.d.ts +10 -2
  33. package/dist/sync/execute.js +44 -18
  34. package/dist/sync/local-files.d.ts +2 -12
  35. package/dist/sync/local-files.js +2 -62
  36. package/dist/sync/segment.d.ts +2 -2
  37. package/dist/sync/segment.js +45 -11
  38. package/package.json +2 -2
  39. package/dist/harness/convexReporting.d.ts +0 -15
  40. package/dist/harness/convexReporting.js +0 -131
  41. package/dist/harness/coverageCache.d.ts +0 -30
  42. package/dist/harness/coverageCache.js +0 -70
@@ -4,7 +4,7 @@
4
4
  * This module contains pure parsing functions with no Node.js dependencies,
5
5
  * making it safe to import in Convex runtime or browser environments.
6
6
  */
7
- import { ValidationError, validateKey, } from "./schemas.js";
7
+ import { REQUIREMENT_KEY_PATTERN, ValidationError, validateKey, } from "./schemas.js";
8
8
  /**
9
9
  * The label stamped on every requirement's root, for indexing and querying.
10
10
  *
@@ -78,7 +78,7 @@ export function parseRootLine(line, delimiter = DELIMITER_PATTERN) {
78
78
  const trimmed = line.trim();
79
79
  // Try to match: KEY + colon + content (explicit key format)
80
80
  // KEY must be followed by colon (not arrow) to distinguish from "Label → content"
81
- const keyMatch = trimmed.match(/^([\w-]+):\s*(.+)$/);
81
+ const keyMatch = trimmed.match(/^([\w-]+):\s*(.*)$/);
82
82
  if (keyMatch) {
83
83
  return {
84
84
  key: keyMatch[1],
@@ -106,7 +106,13 @@ export function parseRootLine(line, delimiter = DELIMITER_PATTERN) {
106
106
  * First line is the requirement content (with optional arrow format).
107
107
  * Subsequent lines are criteria with position paths.
108
108
  */
109
- export function parseRequirementBlock(key, blockContent) {
109
+ export function parseRequirementBlock(key, blockContent,
110
+ /**
111
+ * Zero-based line index of this block within its fence. A fence may group
112
+ * several roots, so without it every root's diagnostics would count from its
113
+ * own first line and "at line 3" would occur once per root.
114
+ */
115
+ lineOffset = 0) {
110
116
  const lines = blockContent.trim().split("\n");
111
117
  if (lines.length === 0) {
112
118
  throw new ValidationError("Empty requirement block", key);
@@ -134,20 +140,27 @@ export function parseRequirementBlock(key, blockContent) {
134
140
  }
135
141
  const criterion = parseCriterionLine(line);
136
142
  if (!criterion) {
137
- // Only a line whose prefix is shaped like a requirement KEY (uppercase
138
- // prefix + "-<digits>", per REQUIREMENT_KEY_PATTERN) suggests a second
139
- // requirement in the block; a stray "https://..." must not be
140
- // misdiagnosed as one.
141
143
  const trimmedLine = line.trim();
142
- if (/^[A-Z][A-Z-]*-\d+:\s*.+/.test(trimmedLine)) {
143
- throw new ValidationError(`Multiple requirements in single block. Found "${trimmedLine.split(":")[0]}" at line ${i + 1}, but each requirement must have its own \`\`\`dotrequirements code block.`, key);
144
+ // SYNC-KEY-1.5: a line written as "KEY: content" is someone starting
145
+ // another requirement, not writing a criterion. Blame the key so the
146
+ // error teaches key format rather than criterion format.
147
+ const rootAttempt = trimmedLine.match(ROOT_ATTEMPT_PATTERN);
148
+ if (rootAttempt) {
149
+ try {
150
+ validateKey(rootAttempt[1]);
151
+ }
152
+ catch (err) {
153
+ // validateKey has no line context, and in a grouped fence naming the
154
+ // key without naming where it is makes the author hunt for it.
155
+ throw new ValidationError(`${err instanceof Error ? err.message : String(err)} (at line ${i + 1 + lineOffset})`, key, err);
156
+ }
144
157
  }
145
- throw new ValidationError(`Invalid criterion format at line ${i + 1}: "${trimmedLine}". Expected format: "N. Label → content" (e.g., "0. Given → user is logged in")`, key);
158
+ throw new ValidationError(`Invalid criterion format at line ${i + 1 + lineOffset}: "${trimmedLine}". Expected format: "N. Label → content" (e.g., "0. Given → user is logged in")`, key);
146
159
  }
147
160
  // SYNC-FORMAT-2.1: a duplicate position is structural ambiguity — reject
148
161
  // it instead of silently dropping the earlier criterion.
149
162
  if (criteriaByPosition.has(criterion.position)) {
150
- throw new ValidationError(`Duplicate criterion position "${criterion.position}" at line ${i + 1} — each position may appear only once per requirement`, key);
163
+ throw new ValidationError(`Duplicate criterion position "${criterion.position}" at line ${i + 1 + lineOffset} — each position may appear only once per requirement`, key);
151
164
  }
152
165
  criteriaByPosition.set(criterion.position, criterion);
153
166
  }
@@ -227,9 +240,104 @@ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByP
227
240
  parent.children.push(childNode);
228
241
  }
229
242
  }
243
+ const EXPLICIT_ROOT_LINE_PATTERN = /^([\w-]+):\s*(.*)$/;
244
+ /**
245
+ * A line someone plainly meant as a root requirement: "KEY: content", where the
246
+ * candidate was reaching for the `PREFIX-<number>` shape — it starts with a
247
+ * letter and either contains a digit or trails off at the hyphen where the
248
+ * number belongs.
249
+ *
250
+ * Used only to pick the right diagnostic (SYNC-KEY-1.5), so it is deliberately
251
+ * narrower than the root grammar. Every exclusion earns its keep by keeping a
252
+ * line that is really a fumbled *criterion* on criterion advice:
253
+ * - leading letter — a mistyped position ("0:" for "0.")
254
+ * - space-or-EOL — a bare "https://…" line, not a key named "https"
255
+ * - digit-or-trailing "-" — "Given:", "Note:", "TODO:", and this project's
256
+ * hyphenated labels ("edit-mode:", "view-mode:").
257
+ * "Given:" for "Given →" is the likeliest fumble of
258
+ * all, since the format itself teaches that word.
259
+ * It catches "LOGIN_2:", "LOGIN2:", "OAUTH2-2:", and "LOGIN-:".
260
+ */
261
+ const ROOT_ATTEMPT_PATTERN = /^([A-Za-z][\w-]*(?:\d[\w-]*|-)):(?:\s|$)/;
262
+ /**
263
+ * Strip the indentation the whole fence shares, so a fence nested inside a
264
+ * Markdown list item still has its roots at column zero. Trimming the body as a
265
+ * whole would de-indent only the first line, leaving every later root indented
266
+ * and therefore invisible as a root.
267
+ */
268
+ function dedentFenceBody(content) {
269
+ const all = content.replace(/\r\n/g, "\n").split("\n");
270
+ let start = 0;
271
+ let end = all.length;
272
+ while (start < end && !all[start].trim())
273
+ start++;
274
+ while (end > start && !all[end - 1].trim())
275
+ end--;
276
+ const lines = all.slice(start, end);
277
+ const indents = lines
278
+ .filter((l) => l.trim())
279
+ .map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
280
+ const common = indents.length ? Math.min(...indents) : 0;
281
+ return {
282
+ lines: lines.map((l) => (l.trim() ? l.slice(common) : l)),
283
+ offset: start,
284
+ };
285
+ }
286
+ /**
287
+ * Split one dotrequirements fence into its independent root requirements.
288
+ *
289
+ * A fence marks a contiguous structured region; each unindented, well-formed
290
+ * KEY: content line starts a new root. The shared key pattern is tested against
291
+ * an uppercase candidate because key validation is case-insensitive
292
+ * (SYNC-KEY-1.3). Authored casing remains untouched in the returned block.
293
+ *
294
+ * Splitting is structural, not a grammar check: it rejects content that is not
295
+ * shaped like a requirement block at all, but a *malformed key* is passed
296
+ * through for the caller to judge. Validating keys here made every reader as
297
+ * strict as the authoring path — a legacy "LOGIN_1" in the cloud took down a
298
+ * whole `dotreq diff` run and rendered as raw markdown in the chat document
299
+ * view. Key grammar is enforced in extractRequirementBlocks (SYNC-KEY-1),
300
+ * on the authoring and save paths.
301
+ */
302
+ export function splitRequirementFenceContent(content) {
303
+ const { lines, offset } = dedentFenceBody(content);
304
+ const firstRoot = lines[0]?.match(EXPLICIT_ROOT_LINE_PATTERN);
305
+ if (!firstRoot) {
306
+ throw new ValidationError(`Invalid requirement block format - first line must be "KEY: content"`, content.substring(0, 50));
307
+ }
308
+ const blocks = [];
309
+ let currentKey = firstRoot[1];
310
+ let currentLines = [lines[0]];
311
+ let currentStart = 0;
312
+ const flushCurrent = () => {
313
+ blocks.push({
314
+ key: currentKey,
315
+ blockContent: currentLines.join("\n").trim(),
316
+ startLine: currentStart + offset,
317
+ });
318
+ };
319
+ for (let i = 1; i < lines.length; i++) {
320
+ const rootMatch = lines[i].match(EXPLICIT_ROOT_LINE_PATTERN);
321
+ const nextKey = rootMatch && REQUIREMENT_KEY_PATTERN.test(rootMatch[1].toUpperCase())
322
+ ? rootMatch[1]
323
+ : undefined;
324
+ if (nextKey !== undefined) {
325
+ flushCurrent();
326
+ currentKey = nextKey;
327
+ currentLines = [lines[i]];
328
+ currentStart = i;
329
+ }
330
+ else {
331
+ currentLines.push(lines[i]);
332
+ }
333
+ }
334
+ flushCurrent();
335
+ return blocks;
336
+ }
230
337
  /**
231
338
  * Extract requirement blocks from Markdown body (no frontmatter).
232
- * Returns an array of { key, blockContent } for each dotrequirements block found.
339
+ * A fence may contain multiple adjacent root requirements; each root is
340
+ * returned as its own logical block.
233
341
  */
234
342
  export function extractRequirementBlocks(body) {
235
343
  const blocks = [];
@@ -238,24 +346,21 @@ export function extractRequirementBlocks(body) {
238
346
  const blockRegex = /```dotrequirements\n([\s\S]*?)```/gm;
239
347
  let match = blockRegex.exec(body);
240
348
  while (match !== null) {
241
- const blockContent = match[1];
242
- // Extract key from first line of block (format: "KEY: content")
243
- const firstLineMatch = blockContent.match(/^([\w-]+):\s*(.+)/);
244
- if (!firstLineMatch) {
245
- throw new ValidationError(`Invalid requirement block format - first line must be "KEY: content"`, blockContent.substring(0, 50));
246
- }
247
- const key = firstLineMatch[1];
248
- // SYNC-KEY-1: reject malformed keys at parse time so push/validate fail
249
- // locally instead of erroring server-side. Validation only — the original
250
- // key is preserved (normalization happens downstream).
251
- validateKey(key);
252
- // SYNC-FORMAT-2.0: a duplicate key is structural ambiguity — reject it
253
- // instead of producing two requirements with the same key.
254
- if (seenKeys.has(key)) {
255
- throw new ValidationError(`Duplicate requirement key "${key}" — each requirement key may appear in only one block`, key);
349
+ const fenceBlocks = splitRequirementFenceContent(match[1]);
350
+ for (const block of fenceBlocks) {
351
+ // SYNC-KEY-1: reject malformed keys at parse time so push/validate fail
352
+ // locally instead of erroring server-side. The original key is preserved
353
+ // in parsed content; normalizedKey is identity-only.
354
+ const normalizedKey = validateKey(block.key);
355
+ // SYNC-FORMAT-2.0: duplicate canonical keys are structural ambiguity,
356
+ // whether they share a fence, use separate fences, or differ by casing
357
+ // or leading zeros.
358
+ if (seenKeys.has(normalizedKey)) {
359
+ throw new ValidationError(`Duplicate requirement key "${block.key}" — each requirement key may appear only once in a document`, block.key);
360
+ }
361
+ seenKeys.add(normalizedKey);
362
+ blocks.push(block);
256
363
  }
257
- seenKeys.add(key);
258
- blocks.push({ key, blockContent: blockContent.trim() });
259
364
  match = blockRegex.exec(body);
260
365
  }
261
366
  return blocks;
@@ -309,9 +414,9 @@ export function parseRequirementBlocksFromMarkdown(markdownContent) {
309
414
  // identically regardless of line-ending style.
310
415
  const blocks = extractRequirementBlocks(markdownContent.replace(/\r\n/g, "\n"));
311
416
  const requirements = [];
312
- for (const { key, blockContent } of blocks) {
417
+ for (const { key, blockContent, startLine } of blocks) {
313
418
  try {
314
- const reqNode = parseRequirementBlock(key, blockContent);
419
+ const reqNode = parseRequirementBlock(key, blockContent, startLine);
315
420
  requirements.push(reqNode);
316
421
  }
317
422
  catch (error) {
@@ -8,7 +8,8 @@
8
8
  * requirements file means (SYNC-FORMAT-2.3).
9
9
  */
10
10
  import { type RequirementNode, type RequirementsFile } from "./schemas.js";
11
- export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, } from "./parser-core.js";
11
+ export type { ExtractedRequirementBlock } from "./parser-core.js";
12
+ export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, splitRequirementFenceContent, } from "./parser-core.js";
12
13
  /**
13
14
  * Parse a complete requirements Markdown file.
14
15
  */
@@ -13,7 +13,7 @@ import { parseRequirementBlocksFromMarkdown, splitFrontmatter, } from "./parser-
13
13
  import { ValidationError, validateMetadata, } from "./schemas.js";
14
14
  // Block/criterion parsing and tree utilities live in parser-core; re-exported
15
15
  // here so the schema module's public surface is unchanged.
16
- export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, } from "./parser-core.js";
16
+ export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, splitRequirementFenceContent, } from "./parser-core.js";
17
17
  /**
18
18
  * Extract YAML frontmatter from Markdown content.
19
19
  * Returns { frontmatter, body } where frontmatter is the parsed YAML object.
@@ -49,6 +49,17 @@ export declare function parseRequirementKey(key: string): {
49
49
  prefix: string;
50
50
  number: number;
51
51
  } | null;
52
+ /**
53
+ * The canonical spelling of a key, for deciding whether two keys name the same
54
+ * requirement. Casing and leading zeros are authoring latitude (SYNC-KEY-1.3)
55
+ * and the cloud stores the canonical form, so "login-02" and "LOGIN-2" must
56
+ * compare equal (DIFF-3.3).
57
+ *
58
+ * A key that doesn't parse has no canonical form; it uppercases instead, so
59
+ * callers that must stay total (sync segmentation) keep a usable identity for a
60
+ * legacy malformed key rather than losing it.
61
+ */
62
+ export declare function canonicalRequirementKey(key: string): string;
52
63
  /**
53
64
  * Build a requirement key from prefix and number.
54
65
  */
@@ -89,6 +89,20 @@ export function parseRequirementKey(key) {
89
89
  return null;
90
90
  return { prefix, number: parseInt(numStr, 10) };
91
91
  }
92
+ /**
93
+ * The canonical spelling of a key, for deciding whether two keys name the same
94
+ * requirement. Casing and leading zeros are authoring latitude (SYNC-KEY-1.3)
95
+ * and the cloud stores the canonical form, so "login-02" and "LOGIN-2" must
96
+ * compare equal (DIFF-3.3).
97
+ *
98
+ * A key that doesn't parse has no canonical form; it uppercases instead, so
99
+ * callers that must stay total (sync segmentation) keep a usable identity for a
100
+ * legacy malformed key rather than losing it.
101
+ */
102
+ export function canonicalRequirementKey(key) {
103
+ const parsed = parseRequirementKey(key);
104
+ return parsed ? `${parsed.prefix}-${parsed.number}` : key.toUpperCase();
105
+ }
92
106
  /**
93
107
  * Build a requirement key from prefix and number.
94
108
  */
@@ -198,7 +198,22 @@ function compareDocument(local, cloud) {
198
198
  if (!diverges && normalizeBody(body) === normalizeBody(cloud.body)) {
199
199
  return { ...base, verdict: "in_sync" };
200
200
  }
201
- const { verdict: bodyVerdict, details } = classifyBodies(body, cloud.body);
201
+ // SYNC-FAIL-4.2: cloud bodies reach the snapshot as stored markdown, never
202
+ // re-parsed, so a document saved before today's grammar can still be
203
+ // unreadable. Segmentation is total, so this is a backstop — and it resolves
204
+ // to "conflict", never "invalid_file": invalid_file maps to "skip" in every
205
+ // sync mode including --repo-wins, which would leave the document permanently
206
+ // unrepairable, and it names the local file for a problem in the cloud copy.
207
+ // Conflict is both honest (we cannot show the change is additive) and
208
+ // actionable (`--repo-wins` overwrites the bad cloud body).
209
+ let classified;
210
+ try {
211
+ classified = classifyBodies(body, cloud.body);
212
+ }
213
+ catch {
214
+ return { ...base, verdict: "conflict" };
215
+ }
216
+ const { verdict: bodyVerdict, details } = classified;
202
217
  let verdict;
203
218
  if (diverges) {
204
219
  // A changed single-valued attribute is never additive → conflict.
@@ -5,7 +5,7 @@
5
5
  * frontmatter write-back) so document-header/import-provenance fidelity is
6
6
  * identical to a repo→cloud push. Local writes reuse local-files.ts.
7
7
  */
8
- import type { PlannedAction } from "./plan.js";
8
+ import type { PlannedAction, SyncMode } from "./plan.js";
9
9
  import type { CloudAuth } from "./snapshot.js";
10
10
  import type { CloudSnapshot, DocumentComparison } from "./types.js";
11
11
  export interface SyncOutcome {
@@ -43,11 +43,19 @@ export interface SyncOutcome {
43
43
  from: string;
44
44
  to: string;
45
45
  }>;
46
+ /** DOC-HEADER-11.4: documents whose prefix was inferred from their first
47
+ * requirement key rather than read from frontmatter. The sync writes that
48
+ * prefix into the file and sends it to the cloud, so it is never a value
49
+ * Jaime has to discover by diffing afterwards. */
50
+ inferredPrefixes: Array<{
51
+ name: string;
52
+ prefix: string;
53
+ }>;
46
54
  }
47
55
  /**
48
56
  * Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
49
57
  * writes require a project slug in `auth`; a read-only share run has none and
50
58
  * its plan carries no cloud-writing actions.
51
59
  */
52
- export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string): Promise<SyncOutcome>;
60
+ export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string, mode: SyncMode): Promise<SyncOutcome>;
53
61
  //# sourceMappingURL=execute.d.ts.map
@@ -10,9 +10,9 @@ import * as path from "node:path";
10
10
  import { ConvexHttpClient } from "convex/browser";
11
11
  import { getConvexUrl } from "../config.js";
12
12
  import { api } from "../convex.js";
13
- import { mergeMetadataWithRawFrontmatter, parseFilesForPushIndividually, WEB_APP_URL, } from "../push/core.js";
14
- import { buildRequirementsFile, composeMarkdownWithTitle, splitLeadingH1, } from "../schema/index.js";
15
- import { resolveLocalPath, writeLocalDocument } from "./local-files.js";
13
+ import { parseFilesForPushIndividually, WEB_APP_URL } from "../push/core.js";
14
+ import { composeMarkdownWithTitle, splitLeadingH1, writeRequirementsFile, } from "../schema/index.js";
15
+ import { resolveLocalPath } from "./local-files.js";
16
16
  function emptyOutcome() {
17
17
  return {
18
18
  cloudCreated: 0,
@@ -27,6 +27,7 @@ function emptyOutcome() {
27
27
  writeBackWarnings: [],
28
28
  importWarnings: [],
29
29
  renames: [],
30
+ inferredPrefixes: [],
30
31
  };
31
32
  }
32
33
  /**
@@ -34,7 +35,7 @@ function emptyOutcome() {
34
35
  * writes require a project slug in `auth`; a read-only share run has none and
35
36
  * its plan carries no cloud-writing actions.
36
37
  */
37
- export async function executePlan(plan, cloud, auth, workspaceRoot) {
38
+ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
38
39
  const outcome = emptyOutcome();
39
40
  const client = new ConvexHttpClient(getConvexUrl());
40
41
  const cloudById = new Map(cloud.documents.map((d) => [d.documentId, d]));
@@ -59,7 +60,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot) {
59
60
  await cloudDelete(client, auth, doc, outcome);
60
61
  break;
61
62
  case "local_write":
62
- localWrite(doc, cloudById, usedPaths, requirementsDir, outcome);
63
+ localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode);
63
64
  break;
64
65
  case "local_delete":
65
66
  localDelete(doc, outcome);
@@ -141,6 +142,16 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
141
142
  outcome.cloudCreated++;
142
143
  else
143
144
  outcome.cloudUpdated++;
145
+ // DOC-HEADER-11.4: the prefix that just went to the cloud was read off the
146
+ // first requirement key, not authored — say which one, so it is never a
147
+ // value Jaime finds out about by diffing. Reported only now that the save
148
+ // has succeeded: a prefix from a push that failed was never used.
149
+ if (parsed.inferredDefaultPrefix && meta?.defaultPrefix) {
150
+ outcome.inferredPrefixes.push({
151
+ name: path.basename(doc.filePath),
152
+ prefix: meta.defaultPrefix,
153
+ });
154
+ }
144
155
  // SYNC-TITLE-1.3: a push that changes the title is a rename — announce it,
145
156
  // so an unintended H1 edit is visible and costs one heading edit to undo.
146
157
  // The new title is derived from the pushed markdown exactly as the server
@@ -167,17 +178,19 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
167
178
  // failure is a warning on a synced document, never a sync failure (which
168
179
  // would invite a retry that mints a duplicate).
169
180
  try {
170
- if (parsed.metadata.document) {
171
- parsed.metadata.document.id = documentId;
172
- // SYNC-TITLE-1.2: drop the legacy frontmatter title — the body already
173
- // carries the name as its leading H1 (materialized above, before the
174
- // push), so the rewritten file loses the field without losing the title.
175
- delete parsed.metadata.document.title;
176
- }
177
- parsed.metadata.pulledAt = new Date().toISOString();
178
- parsed.metadata.version = (parsed.metadata.version ?? 0) + 1;
179
- const content = buildRequirementsFile(mergeMetadataWithRawFrontmatter(parsed.metadata, parsed.rawFrontmatter), parsed.markdownContent);
180
- fs.writeFileSync(doc.filePath, content, "utf-8");
181
+ // The legacy frontmatter title is not carried over (SYNC-TITLE-1.2) — the
182
+ // body already carries the name as its leading H1 (materialized above,
183
+ // before the push), so the rewritten file loses the field without losing
184
+ // the title.
185
+ writeRequirementsFile(doc.filePath, {
186
+ documentId,
187
+ // The prefix just pushed is the file's own (or the one inferred from
188
+ // its first key, DOC-HEADER-11.4, which the write-back materializes).
189
+ defaultPrefix: meta?.defaultPrefix
190
+ ? { set: meta.defaultPrefix }
191
+ : "keep",
192
+ version: (parsed.metadata.version ?? 0) + 1,
193
+ }, parsed.markdownContent);
181
194
  }
182
195
  catch (writeErr) {
183
196
  outcome.writeBackWarnings.push({
@@ -202,7 +215,7 @@ async function cloudDelete(client, auth, doc, outcome) {
202
215
  });
203
216
  outcome.cloudDeleted++;
204
217
  }
205
- function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
218
+ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode) {
206
219
  const cloudDoc = doc.documentId ? cloudById.get(doc.documentId) : undefined;
207
220
  if (!cloudDoc)
208
221
  throw new Error(`No cloud content for ${doc.title}`);
@@ -210,7 +223,20 @@ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
210
223
  fs.mkdirSync(requirementsDir, { recursive: true });
211
224
  const filePath = resolveLocalPath(cloudDoc, doc.filePath, usedPaths, requirementsDir);
212
225
  usedPaths.add(filePath);
213
- const changed = writeLocalDocument(filePath, cloudDoc);
226
+ // DOC-HEADER-11.6/11.7: a cloud document with no prefix means "nothing to
227
+ // impose" under an additive sync and "no prefix" under cloud-wins authority
228
+ // — so a prefix Jaime wrote is only ever removed by the mode they ran to
229
+ // make the repo match the cloud.
230
+ const defaultPrefix = cloudDoc.defaultPrefix
231
+ ? { set: cloudDoc.defaultPrefix }
232
+ : mode === "cloud_wins"
233
+ ? "clear"
234
+ : "keep";
235
+ const changed = writeRequirementsFile(filePath, {
236
+ documentId: cloudDoc.documentId,
237
+ defaultPrefix,
238
+ version: cloudDoc.version,
239
+ }, cloudDoc.body);
214
240
  if (changed)
215
241
  outcome.localWritten++;
216
242
  }
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Writing cloud content into local `.requirements/` files — the local half of
3
- * sync. Shared filename/collision logic (SYNC-WEB-CREATE-2) and the frontmatter
4
- * build (SYNC-WRITE-1) live here so pull and sync agree.
2
+ * Choosing where cloud content lands in `.requirements/` — the local half of
3
+ * sync. The bytes themselves are written by `writeRequirementsFile`.
5
4
  */
6
5
  import type { CloudDocument } from "./types.js";
7
6
  /** Sanitize a document title into a filename stem. */
@@ -14,13 +13,4 @@ export declare function sanitizeFileName(title: string): string;
14
13
  * (SYNC-WEB-CREATE-2).
15
14
  */
16
15
  export declare function resolveLocalPath(cloud: CloudDocument, existingPath: string | undefined, usedPaths: Set<string>, requirementsDir: string): string;
17
- /** Read the ctsRun marker from an existing file's frontmatter, if any (IMPORT-1). */
18
- export declare function readCtsRun(filePath: string): string | undefined;
19
- /**
20
- * Write a cloud document to a local file. `pulledAt` records when this content
21
- * arrived (SYNC-WRITE-1.1); an existing ctsRun marker is carried forward
22
- * (IMPORT-1). Returns true when the file's bytes actually changed — sync only
23
- * touches files whose content changed (SYNC-WRITE-1.0).
24
- */
25
- export declare function writeLocalDocument(filePath: string, cloud: CloudDocument): boolean;
26
16
  //# sourceMappingURL=local-files.d.ts.map
@@ -1,12 +1,9 @@
1
1
  /**
2
- * Writing cloud content into local `.requirements/` files — the local half of
3
- * sync. Shared filename/collision logic (SYNC-WEB-CREATE-2) and the frontmatter
4
- * build (SYNC-WRITE-1) live here so pull and sync agree.
2
+ * Choosing where cloud content lands in `.requirements/` — the local half of
3
+ * sync. The bytes themselves are written by `writeRequirementsFile`.
5
4
  */
6
5
  import * as fs from "node:fs";
7
6
  import * as path from "node:path";
8
- import { buildRequirementsFile } from "../schema/index.js";
9
- import { extractFrontmatterBlock } from "../schema/parser-core.js";
10
7
  /** Sanitize a document title into a filename stem. */
11
8
  export function sanitizeFileName(title) {
12
9
  return title
@@ -33,61 +30,4 @@ export function resolveLocalPath(cloud, existingPath, usedPaths, requirementsDir
33
30
  }
34
31
  return candidate;
35
32
  }
36
- /** Read the ctsRun marker from an existing file's frontmatter, if any (IMPORT-1). */
37
- export function readCtsRun(filePath) {
38
- if (!fs.existsSync(filePath))
39
- return undefined;
40
- // Search only the frontmatter block — a body line starting "ctsRun:" must
41
- // not be mistaken for the marker.
42
- const yaml = extractFrontmatterBlock(fs.readFileSync(filePath, "utf-8"));
43
- const match = yaml?.match(/^ctsRun: (.+)$/m);
44
- return match?.[1].trim();
45
- }
46
- /**
47
- * Write a cloud document to a local file. `pulledAt` records when this content
48
- * arrived (SYNC-WRITE-1.1); an existing ctsRun marker is carried forward
49
- * (IMPORT-1). Returns true when the file's bytes actually changed — sync only
50
- * touches files whose content changed (SYNC-WRITE-1.0).
51
- */
52
- export function writeLocalDocument(filePath, cloud) {
53
- const existingCtsRun = readCtsRun(filePath);
54
- // SYNC-TITLE-1.2: no `document.title` in frontmatter — the title lives in
55
- // the body as its leading H1. A legacy file that gets rewritten loses the
56
- // field here, since the metadata is rebuilt from scratch.
57
- const metadata = {
58
- pulledAt: new Date().toISOString(),
59
- version: cloud.version,
60
- ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
61
- document: {
62
- id: cloud.documentId,
63
- defaultPrefix: cloud.defaultPrefix,
64
- },
65
- };
66
- const content = buildRequirementsFile(metadata, cloud.body);
67
- // SYNC-WRITE-1.0: skip the write when the body+identity are unchanged, so a
68
- // no-op sync leaves the working tree clean. Compare ignoring the volatile
69
- // pulledAt line, which would otherwise force a rewrite every run.
70
- if (fs.existsSync(filePath)) {
71
- const current = fs.readFileSync(filePath, "utf-8");
72
- if (withoutPulledAt(current) === withoutPulledAt(content))
73
- return false;
74
- }
75
- fs.writeFileSync(filePath, content, "utf-8");
76
- return true;
77
- }
78
- /**
79
- * Normalize the volatile pulledAt line for equality comparison — within the
80
- * frontmatter only, so a body line that happens to start "pulledAt:" can never
81
- * mask a genuine content change (which would silently skip the write).
82
- */
83
- function withoutPulledAt(fileContent) {
84
- // CRLF-normalize first: extractFrontmatterBlock returns LF-normalized yaml,
85
- // so the replace below must operate on LF content to find it.
86
- const normalized = fileContent.replace(/\r\n/g, "\n");
87
- const yaml = extractFrontmatterBlock(normalized);
88
- if (yaml === undefined)
89
- return normalized;
90
- const normalizedYaml = yaml.replace(/^pulledAt: .*$/m, "pulledAt: <normalized>");
91
- return normalized.replace(yaml, normalizedYaml);
92
- }
93
33
  //# sourceMappingURL=local-files.js.map
@@ -23,8 +23,8 @@ export type Unit = {
23
23
  export declare function normalizeProse(text: string): string;
24
24
  /**
25
25
  * Split a body into its ordered units. Prose between (and around) fences
26
- * becomes prose units when non-empty after normalization; each fence becomes a
27
- * requirement unit keyed by its first-line KEY.
26
+ * becomes prose units when non-empty after normalization; every root inside a
27
+ * fence becomes a requirement unit keyed by its KEY.
28
28
  */
29
29
  export declare function segmentBody(body: string): Unit[];
30
30
  /**