@popoverai/dotrequirements 0.28.0 → 0.29.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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Compose stage: assemble per-area partials into a single composed spec.
3
3
  *
4
- * The composed document has YAML frontmatter (title, defaultPrefix), an H1
5
- * title, a summary paragraph from the outline, and one H2 section per area.
4
+ * The composed document has YAML frontmatter (defaultPrefix), an H1 title —
5
+ * the document's name (SYNC-TITLE-1) — a summary paragraph from the outline,
6
+ * and one H2 section per area.
6
7
  * Each area section contains the partial's content.
7
8
  *
8
9
  * After assembly, a deterministic renumbering pass rewrites each prefix's
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Compose stage: assemble per-area partials into a single composed spec.
3
3
  *
4
- * The composed document has YAML frontmatter (title, defaultPrefix), an H1
5
- * title, a summary paragraph from the outline, and one H2 section per area.
4
+ * The composed document has YAML frontmatter (defaultPrefix), an H1 title —
5
+ * the document's name (SYNC-TITLE-1) — a summary paragraph from the outline,
6
+ * and one H2 section per area.
6
7
  * Each area section contains the partial's content.
7
8
  *
8
9
  * After assembly, a deterministic renumbering pass rewrites each prefix's
@@ -48,7 +49,6 @@ export function assembleComposedDocument(outline, partialPathFor) {
48
49
  const parts = [];
49
50
  parts.push("---");
50
51
  parts.push("document:");
51
- parts.push(` title: "${escapeYamlString(outline.title)}"`);
52
52
  parts.push(` defaultPrefix: ${outline.defaultPrefix}`);
53
53
  parts.push("---");
54
54
  parts.push("");
@@ -114,6 +114,11 @@ export async function syncCommand(scope, options) {
114
114
  for (const err of outcome.errors) {
115
115
  console.log(` ✗ failed: ${err.name} — ${err.error}`);
116
116
  }
117
+ // SYNC-TITLE-1.3: a push that changed a title is a rename — say so, so an
118
+ // unintended H1 edit is visible and costs one heading edit to undo.
119
+ for (const r of outcome.renames) {
120
+ console.log(` renamed "${r.from}" → "${r.to}"`);
121
+ }
117
122
  // IMPORT-3: marker failures are loud but never fatal.
118
123
  for (const { name, status } of outcome.importWarnings) {
119
124
  if (status === "already_used") {
package/dist/push/core.js CHANGED
@@ -65,6 +65,13 @@ export function mergeMetadataWithRawFrontmatter(metadata, rawFrontmatter) {
65
65
  ...rawDocument,
66
66
  ...metadata.document,
67
67
  };
68
+ // SYNC-TITLE-1.2: `document.title` is a recognized-but-retired field —
69
+ // the title lives in the body as its leading H1 — so a rewrite drops it
70
+ // rather than preserving it as if it were a user's custom field
71
+ // (DOC-HEADER-14 protects unrecognized fields, not this one).
72
+ if (!("title" in metadata.document)) {
73
+ delete merged.document.title;
74
+ }
68
75
  }
69
76
  return merged;
70
77
  }
@@ -17,6 +17,8 @@ export interface FlattenedRequirement {
17
17
  export interface ParsedRequirementsFile {
18
18
  metadata: Metadata;
19
19
  requirements: RequirementNode[];
20
+ /** Markdown body; its leading H1 is the document's title (SYNC-TITLE-1). */
21
+ body?: string;
20
22
  }
21
23
  /**
22
24
  * Find all *.requirements.md files in the workspace.
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { glob, globSync } from "glob";
3
3
  import { buildRequirementMarkdown, getAllRequirements, parseRequirementsFile, parseRequirementsFromFile, } from "../schema/index.js";
4
+ import { splitLeadingH1 } from "../schema/title-markdown.js";
4
5
  /**
5
6
  * Glob pattern matching *.requirements.md files.
6
7
  */
@@ -90,7 +91,11 @@ export function flattenRequirementsFile(file, sourceFile) {
90
91
  content: req.content,
91
92
  path: idParts.slice(1), // ["0", "1"] for "REQ123.0.1"
92
93
  sourceFile: sourceFile,
93
- documentTitle: file.metadata.document?.title || rootId,
94
+ // SYNC-TITLE-1: the body's leading H1 is the title; legacy
95
+ // frontmatter title as fallback for pre-H1 files
96
+ documentTitle: splitLeadingH1(file.body ?? "").title ||
97
+ file.metadata.document?.title ||
98
+ rootId,
94
99
  });
95
100
  }
96
101
  return results;
@@ -125,7 +125,7 @@ ${customStyleGuidance.trim()}`;
125
125
  }
126
126
  const template = `---
127
127
  document:
128
- title: "Example Requirements"
128
+ defaultPrefix: EXAMPLE
129
129
  ---
130
130
 
131
131
  # Example Requirements
@@ -9,6 +9,7 @@ export { buildMetadata, constructKey, convexToRequirements, extractRequirementKe
9
9
  export { DELIMITER_PATTERN, extractRequirementBlocks, findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRequirementBlocksFromMarkdown, parseRootLine, ROOT_LABEL_MARKER, } from "./parser-core.js";
10
10
  export type { Metadata, ParsedCriterion, RequirementKey, RequirementNode, RequirementPrefix, RequirementsFile, } from "./schemas.js";
11
11
  export { buildRequirementKey, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
12
+ export { composeMarkdownWithTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
12
13
  export type { BuildScenarioOptions, Scenario, ScenarioAssertionSource, ScenarioStep, } from "./scenario.js";
13
14
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
14
15
  //# sourceMappingURL=browser.d.ts.map
@@ -22,6 +22,9 @@ normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PAT
22
22
  REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema,
23
23
  // Validation
24
24
  ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
25
+ // Title semantics (pure TypeScript - browser-safe): the document title and
26
+ // the markdown's leading H1 are the same thing (DOC-TITLE-3)
27
+ export { composeMarkdownWithTitle, splitLeadingH1, } from "./title-markdown.js";
25
28
  // Scenario building (pure TypeScript - browser-safe, used by Convex Node actions)
26
29
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
27
30
  //# sourceMappingURL=browser.js.map
@@ -13,4 +13,5 @@ export type { BuildScenarioOptions, Scenario, ScenarioAssertionSource, ScenarioS
13
13
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
14
14
  export type { Metadata, ParsedCriterion, PushValidationResult, RequirementKey, RequirementNode, RequirementPrefix, RequirementsFile, } from "./schemas.js";
15
15
  export { buildRequirementKey, CONVEX_ID_PATTERN, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateForPush, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
16
+ export { composeMarkdownWithTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -23,4 +23,5 @@ normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PAT
23
23
  REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema,
24
24
  // Validation
25
25
  ValidationError, validateForPush, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
26
+ export { composeMarkdownWithTitle, splitLeadingH1, } from "./title-markdown.js";
26
27
  //# sourceMappingURL=index.js.map
@@ -15,6 +15,8 @@ export { findRequirementById, flattenRequirementTree, getAllRequirements, parseC
15
15
  export declare function parseRequirementsFile(markdownContent: string): {
16
16
  metadata: RequirementsFile["_meta"];
17
17
  requirements: RequirementNode[];
18
+ /** The markdown body (frontmatter stripped) — its leading H1 is the document's title (SYNC-TITLE-1). */
19
+ body: string;
18
20
  };
19
21
  /**
20
22
  * Parse requirements file from disk.
@@ -22,5 +24,6 @@ export declare function parseRequirementsFile(markdownContent: string): {
22
24
  export declare function parseRequirementsFromFile(filePath: string): {
23
25
  metadata: RequirementsFile["_meta"];
24
26
  requirements: RequirementNode[];
27
+ body: string;
25
28
  };
26
29
  //# sourceMappingURL=parser.d.ts.map
@@ -47,7 +47,7 @@ export function parseRequirementsFile(markdownContent) {
47
47
  if (requirements.length === 0) {
48
48
  throw new ValidationError("No requirement blocks found in document");
49
49
  }
50
- return { metadata, requirements };
50
+ return { metadata, requirements, body };
51
51
  }
52
52
  /**
53
53
  * Parse requirements file from disk.
@@ -54,8 +54,10 @@ export declare function parseRequirementKey(key: string): {
54
54
  */
55
55
  export declare function buildRequirementKey(prefix: string, number: number): string;
56
56
  /**
57
- * Metadata block at the top of every requirements file.
58
- * Only document.title is required for push; everything else is optional or auto-filled.
57
+ * Metadata block at the top of every requirements file. Everything is
58
+ * optional or auto-filled; the document's title lives in the BODY as its
59
+ * leading H1 (SYNC-TITLE-1), with a legacy frontmatter title tolerated as a
60
+ * fallback for files from before that convention.
59
61
  */
60
62
  export declare const MetadataSchema: z.ZodObject<{
61
63
  version: z.ZodOptional<z.ZodNumber>;
@@ -63,15 +65,15 @@ export declare const MetadataSchema: z.ZodObject<{
63
65
  ctsRun: z.ZodOptional<z.ZodString>;
64
66
  document: z.ZodOptional<z.ZodObject<{
65
67
  id: z.ZodOptional<z.ZodString>;
66
- title: z.ZodString;
68
+ title: z.ZodOptional<z.ZodString>;
67
69
  defaultPrefix: z.ZodOptional<z.ZodString>;
68
70
  }, "strip", z.ZodTypeAny, {
69
- title: string;
70
71
  id?: string | undefined;
72
+ title?: string | undefined;
71
73
  defaultPrefix?: string | undefined;
72
74
  }, {
73
- title: string;
74
75
  id?: string | undefined;
76
+ title?: string | undefined;
75
77
  defaultPrefix?: string | undefined;
76
78
  }>>;
77
79
  }, "strip", z.ZodTypeAny, {
@@ -79,8 +81,8 @@ export declare const MetadataSchema: z.ZodObject<{
79
81
  pulledAt?: string | undefined;
80
82
  ctsRun?: string | undefined;
81
83
  document?: {
82
- title: string;
83
84
  id?: string | undefined;
85
+ title?: string | undefined;
84
86
  defaultPrefix?: string | undefined;
85
87
  } | undefined;
86
88
  }, {
@@ -88,8 +90,8 @@ export declare const MetadataSchema: z.ZodObject<{
88
90
  pulledAt?: string | undefined;
89
91
  ctsRun?: string | undefined;
90
92
  document?: {
91
- title: string;
92
93
  id?: string | undefined;
94
+ title?: string | undefined;
93
95
  defaultPrefix?: string | undefined;
94
96
  } | undefined;
95
97
  }>;
@@ -152,15 +154,15 @@ export declare const RequirementsFileSchema: z.ZodObject<{
152
154
  ctsRun: z.ZodOptional<z.ZodString>;
153
155
  document: z.ZodOptional<z.ZodObject<{
154
156
  id: z.ZodOptional<z.ZodString>;
155
- title: z.ZodString;
157
+ title: z.ZodOptional<z.ZodString>;
156
158
  defaultPrefix: z.ZodOptional<z.ZodString>;
157
159
  }, "strip", z.ZodTypeAny, {
158
- title: string;
159
160
  id?: string | undefined;
161
+ title?: string | undefined;
160
162
  defaultPrefix?: string | undefined;
161
163
  }, {
162
- title: string;
163
164
  id?: string | undefined;
165
+ title?: string | undefined;
164
166
  defaultPrefix?: string | undefined;
165
167
  }>>;
166
168
  }, "strip", z.ZodTypeAny, {
@@ -168,8 +170,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
168
170
  pulledAt?: string | undefined;
169
171
  ctsRun?: string | undefined;
170
172
  document?: {
171
- title: string;
172
173
  id?: string | undefined;
174
+ title?: string | undefined;
173
175
  defaultPrefix?: string | undefined;
174
176
  } | undefined;
175
177
  }, {
@@ -177,8 +179,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
177
179
  pulledAt?: string | undefined;
178
180
  ctsRun?: string | undefined;
179
181
  document?: {
180
- title: string;
181
182
  id?: string | undefined;
183
+ title?: string | undefined;
182
184
  defaultPrefix?: string | undefined;
183
185
  } | undefined;
184
186
  }>;
@@ -189,15 +191,15 @@ export declare const RequirementsFileSchema: z.ZodObject<{
189
191
  ctsRun: z.ZodOptional<z.ZodString>;
190
192
  document: z.ZodOptional<z.ZodObject<{
191
193
  id: z.ZodOptional<z.ZodString>;
192
- title: z.ZodString;
194
+ title: z.ZodOptional<z.ZodString>;
193
195
  defaultPrefix: z.ZodOptional<z.ZodString>;
194
196
  }, "strip", z.ZodTypeAny, {
195
- title: string;
196
197
  id?: string | undefined;
198
+ title?: string | undefined;
197
199
  defaultPrefix?: string | undefined;
198
200
  }, {
199
- title: string;
200
201
  id?: string | undefined;
202
+ title?: string | undefined;
201
203
  defaultPrefix?: string | undefined;
202
204
  }>>;
203
205
  }, "strip", z.ZodTypeAny, {
@@ -205,8 +207,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
205
207
  pulledAt?: string | undefined;
206
208
  ctsRun?: string | undefined;
207
209
  document?: {
208
- title: string;
209
210
  id?: string | undefined;
211
+ title?: string | undefined;
210
212
  defaultPrefix?: string | undefined;
211
213
  } | undefined;
212
214
  }, {
@@ -214,8 +216,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
214
216
  pulledAt?: string | undefined;
215
217
  ctsRun?: string | undefined;
216
218
  document?: {
217
- title: string;
218
219
  id?: string | undefined;
220
+ title?: string | undefined;
219
221
  defaultPrefix?: string | undefined;
220
222
  } | undefined;
221
223
  }>;
@@ -226,15 +228,15 @@ export declare const RequirementsFileSchema: z.ZodObject<{
226
228
  ctsRun: z.ZodOptional<z.ZodString>;
227
229
  document: z.ZodOptional<z.ZodObject<{
228
230
  id: z.ZodOptional<z.ZodString>;
229
- title: z.ZodString;
231
+ title: z.ZodOptional<z.ZodString>;
230
232
  defaultPrefix: z.ZodOptional<z.ZodString>;
231
233
  }, "strip", z.ZodTypeAny, {
232
- title: string;
233
234
  id?: string | undefined;
235
+ title?: string | undefined;
234
236
  defaultPrefix?: string | undefined;
235
237
  }, {
236
- title: string;
237
238
  id?: string | undefined;
239
+ title?: string | undefined;
238
240
  defaultPrefix?: string | undefined;
239
241
  }>>;
240
242
  }, "strip", z.ZodTypeAny, {
@@ -242,8 +244,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
242
244
  pulledAt?: string | undefined;
243
245
  ctsRun?: string | undefined;
244
246
  document?: {
245
- title: string;
246
247
  id?: string | undefined;
248
+ title?: string | undefined;
247
249
  defaultPrefix?: string | undefined;
248
250
  } | undefined;
249
251
  }, {
@@ -251,8 +253,8 @@ export declare const RequirementsFileSchema: z.ZodObject<{
251
253
  pulledAt?: string | undefined;
252
254
  ctsRun?: string | undefined;
253
255
  document?: {
254
- title: string;
255
256
  id?: string | undefined;
257
+ title?: string | undefined;
256
258
  defaultPrefix?: string | undefined;
257
259
  } | undefined;
258
260
  }>;
@@ -106,8 +106,10 @@ export function buildRequirementKey(prefix, number) {
106
106
  // Metadata Schema
107
107
  // =============================================================================
108
108
  /**
109
- * Metadata block at the top of every requirements file.
110
- * Only document.title is required for push; everything else is optional or auto-filled.
109
+ * Metadata block at the top of every requirements file. Everything is
110
+ * optional or auto-filled; the document's title lives in the BODY as its
111
+ * leading H1 (SYNC-TITLE-1), with a legacy frontmatter title tolerated as a
112
+ * fallback for files from before that convention.
111
113
  */
112
114
  export const MetadataSchema = z.object({
113
115
  version: z.number().optional(), // Informational - incremented on push
@@ -116,7 +118,7 @@ export const MetadataSchema = z.object({
116
118
  document: z
117
119
  .object({
118
120
  id: z.string().optional(), // Cloud document link - filled by push when creating
119
- title: z.string(), // Required for push
121
+ title: z.string().optional(), // Legacy fallback — the body's leading H1 is the title
120
122
  defaultPrefix: z.string().optional(), // Editor hint for new requirement keys
121
123
  })
122
124
  .optional(),
@@ -136,18 +138,13 @@ export const CONVEX_ID_PATTERN = /^j[a-z0-9]{31}$/;
136
138
  * SYNC-CLI-EDIT-1: Files with valid document.id update existing documents
137
139
  */
138
140
  export function validateForPush(metadata) {
139
- // Must have document section
141
+ // Must have document section. (A title is NOT required — untitled
142
+ // documents are allowed, and the title is derived from the markdown's
143
+ // leading H1 anyway; see docs/working/h1-as-title.md.)
140
144
  if (!metadata.document) {
141
145
  return {
142
146
  valid: false,
143
- reason: "Missing document section. Add a document section with a title to sync this file.",
144
- };
145
- }
146
- // Must have title
147
- if (!metadata.document.title || metadata.document.title.trim() === "") {
148
- return {
149
- valid: false,
150
- reason: "Document section missing required title.",
147
+ reason: "Missing document section. Add a document section to sync this file.",
151
148
  };
152
149
  }
153
150
  // Check document.id
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The document title and the markdown's leading H1 are the same thing
3
+ * (DOC-TITLE-3). These helpers hold that contract at the web app's storage
4
+ * boundary: markdown coming *in* from Convex is split into title + body, and
5
+ * markdown going *out* is recomposed — so inside the app, `Content.markdown`
6
+ * is always the body (no leading H1) and `Content.title` carries the title.
7
+ */
8
+ export interface SplitMarkdown {
9
+ /** The leading H1's text, or null when the markdown has no leading H1. */
10
+ title: string | null;
11
+ /** Everything after the leading H1 (or the whole input when there is none). */
12
+ body: string;
13
+ }
14
+ /**
15
+ * Split a leading ATX H1 (`# Title`) off the markdown.
16
+ *
17
+ * "Leading" means the first non-blank line (DOC-TITLE-3.0); an H1 further
18
+ * down is ordinary content and stays in the body (DOC-TITLE-3.4). Markdown
19
+ * allows up to 3 spaces of heading indentation — 4+ is a code block, so it
20
+ * is not a title.
21
+ */
22
+ export declare function splitLeadingH1(markdown: string): SplitMarkdown;
23
+ /**
24
+ * Compose full-document markdown from a title and a body.
25
+ *
26
+ * The title becomes the leading H1 (DOC-TITLE-3.1); an untitled document
27
+ * gets no empty heading line (DOC-TITLE-3.3).
28
+ */
29
+ export declare function composeMarkdownWithTitle(title: string, body: string): string;
30
+ //# sourceMappingURL=title-markdown.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The document title and the markdown's leading H1 are the same thing
3
+ * (DOC-TITLE-3). These helpers hold that contract at the web app's storage
4
+ * boundary: markdown coming *in* from Convex is split into title + body, and
5
+ * markdown going *out* is recomposed — so inside the app, `Content.markdown`
6
+ * is always the body (no leading H1) and `Content.title` carries the title.
7
+ */
8
+ /**
9
+ * Split a leading ATX H1 (`# Title`) off the markdown.
10
+ *
11
+ * "Leading" means the first non-blank line (DOC-TITLE-3.0); an H1 further
12
+ * down is ordinary content and stays in the body (DOC-TITLE-3.4). Markdown
13
+ * allows up to 3 spaces of heading indentation — 4+ is a code block, so it
14
+ * is not a title.
15
+ */
16
+ export function splitLeadingH1(markdown) {
17
+ const match = /^(?:[ \t]*\r?\n)* {0,3}# (.*)(?:\r?\n|$)/.exec(markdown);
18
+ if (!match) {
19
+ return { title: null, body: markdown };
20
+ }
21
+ const body = markdown.slice(match[0].length).replace(/^(\s*\r?\n)+/, "");
22
+ return { title: match[1].trim(), body };
23
+ }
24
+ /**
25
+ * Compose full-document markdown from a title and a body.
26
+ *
27
+ * The title becomes the leading H1 (DOC-TITLE-3.1); an untitled document
28
+ * gets no empty heading line (DOC-TITLE-3.3).
29
+ */
30
+ export function composeMarkdownWithTitle(title, body) {
31
+ const trimmedTitle = title.trim();
32
+ if (!trimmedTitle)
33
+ return body;
34
+ if (!body.trim())
35
+ return `# ${trimmedTitle}\n`;
36
+ return `# ${trimmedTitle}\n\n${body}`;
37
+ }
38
+ //# sourceMappingURL=title-markdown.js.map
@@ -38,6 +38,11 @@ export interface SyncOutcome {
38
38
  name: string;
39
39
  status: string;
40
40
  }>;
41
+ /** SYNC-TITLE-1.3: pushes that changed a document's title. */
42
+ renames: Array<{
43
+ from: string;
44
+ to: string;
45
+ }>;
41
46
  }
42
47
  /**
43
48
  * Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
@@ -11,7 +11,7 @@ import { ConvexHttpClient } from "convex/browser";
11
11
  import { getConvexUrl } from "../config.js";
12
12
  import { api } from "../convex.js";
13
13
  import { mergeMetadataWithRawFrontmatter, parseFilesForPushIndividually, WEB_APP_URL, } from "../push/core.js";
14
- import { buildRequirementsFile } from "../schema/index.js";
14
+ import { buildRequirementsFile, composeMarkdownWithTitle, splitLeadingH1, } from "../schema/index.js";
15
15
  import { resolveLocalPath, writeLocalDocument } from "./local-files.js";
16
16
  function emptyOutcome() {
17
17
  return {
@@ -26,6 +26,7 @@ function emptyOutcome() {
26
26
  synced: [],
27
27
  writeBackWarnings: [],
28
28
  importWarnings: [],
29
+ renames: [],
29
30
  };
30
31
  }
31
32
  /**
@@ -52,7 +53,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot) {
52
53
  outcome.invalid.push(doc);
53
54
  break;
54
55
  case "cloud_write":
55
- await cloudWrite(client, auth, doc, outcome);
56
+ await cloudWrite(client, auth, doc, cloudById, outcome);
56
57
  break;
57
58
  case "cloud_delete":
58
59
  await cloudDelete(client, auth, doc, outcome);
@@ -82,7 +83,7 @@ function errorDisplayMessage(err) {
82
83
  return data.message;
83
84
  return err instanceof Error ? err.message : String(err);
84
85
  }
85
- async function cloudWrite(client, auth, doc, outcome) {
86
+ async function cloudWrite(client, auth, doc, cloudById, outcome) {
86
87
  if (!auth.projectSlug || !doc.filePath) {
87
88
  throw new Error("Cannot write to the cloud without project credentials.");
88
89
  }
@@ -91,6 +92,21 @@ async function cloudWrite(client, auth, doc, outcome) {
91
92
  if (!parsed)
92
93
  throw new Error(`Could not parse ${path.basename(doc.filePath)}`);
93
94
  const meta = parsed.metadata.document;
95
+ // SYNC-TITLE-1.1: a legacy file whose title lives only in frontmatter (no body
96
+ // H1) must reach the cloud self-describing. Materialize the title as the body's
97
+ // leading H1 BEFORE the push — so the cloud row carries the H1 on first write
98
+ // and never lands as an H1-less-but-titled row. Such a row would otherwise be
99
+ // reachable to a web/MCP edit that untitles it (and is out of reach of the
100
+ // one-time title backfill, since a legacy checkout mints it on any later sync).
101
+ // Only the unambiguous case fires: a known frontmatter title with no existing
102
+ // H1 — never a guess about a content H1.
103
+ if (meta?.title && splitLeadingH1(parsed.markdownContent).title === null) {
104
+ parsed.markdownContent = composeMarkdownWithTitle(meta.title, parsed.markdownContent);
105
+ }
106
+ // SYNC-TITLE-1: the server derives the title from the body's leading H1.
107
+ // A legacy frontmatter title rides along only as the server's fallback for
108
+ // H1-less bodies (SYNC-TITLE-1.1); the filename is never a title
109
+ // (SYNC-TITLE-1.0).
94
110
  const saveResult = (await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
95
111
  projectAuth: {
96
112
  projectSlug: auth.projectSlug,
@@ -98,7 +114,7 @@ async function cloudWrite(client, auth, doc, outcome) {
98
114
  },
99
115
  target: { type: "project", slug: auth.projectSlug },
100
116
  documentId: meta?.id,
101
- title: meta?.title ?? path.basename(doc.filePath),
117
+ title: meta?.title,
102
118
  markdownContent: parsed.markdownContent,
103
119
  defaultPrefix: meta?.defaultPrefix,
104
120
  runMarker: parsed.metadata.ctsRun,
@@ -125,6 +141,24 @@ async function cloudWrite(client, auth, doc, outcome) {
125
141
  outcome.cloudCreated++;
126
142
  else
127
143
  outcome.cloudUpdated++;
144
+ // SYNC-TITLE-1.3: a push that changes the title is a rename — announce it,
145
+ // so an unintended H1 edit is visible and costs one heading edit to undo.
146
+ // The new title is derived from the pushed markdown exactly as the server
147
+ // derives it (H1, else the legacy frontmatter title, else untitled) — NOT
148
+ // from doc.title, which the comparator pins to the OLD cloud title for any
149
+ // id-matched document, so comparing against it never detects a rename.
150
+ const cloudBefore = meta?.id ? cloudById.get(meta.id) : undefined;
151
+ if (cloudBefore) {
152
+ const newTitle = splitLeadingH1(parsed.markdownContent).title ?? meta?.title ?? "";
153
+ // NAV-TITLE-1.2: the cloud snapshot reports an untitled document's title as
154
+ // the display fallback "Untitled Document", while newTitle is "" for that
155
+ // same state. Normalize before comparing so a content-only push of an
156
+ // untitled doc doesn't report a phantom `"Untitled Document" → ""` rename.
157
+ const before = cloudBefore.title === "Untitled Document" ? "" : cloudBefore.title;
158
+ if (before !== newTitle) {
159
+ outcome.renames.push({ from: before, to: newTitle });
160
+ }
161
+ }
128
162
  outcome.synced.push({
129
163
  name: path.basename(doc.filePath),
130
164
  url: `${WEB_APP_URL}/documents/${documentId}`,
@@ -133,8 +167,13 @@ async function cloudWrite(client, auth, doc, outcome) {
133
167
  // failure is a warning on a synced document, never a sync failure (which
134
168
  // would invite a retry that mints a duplicate).
135
169
  try {
136
- if (parsed.metadata.document)
170
+ if (parsed.metadata.document) {
137
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
+ }
138
177
  parsed.metadata.pulledAt = new Date().toISOString();
139
178
  parsed.metadata.version = (parsed.metadata.version ?? 0) + 1;
140
179
  const content = buildRequirementsFile(mergeMetadataWithRawFrontmatter(parsed.metadata, parsed.rawFrontmatter), parsed.markdownContent);
@@ -51,13 +51,15 @@ export function readCtsRun(filePath) {
51
51
  */
52
52
  export function writeLocalDocument(filePath, cloud) {
53
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.
54
57
  const metadata = {
55
58
  pulledAt: new Date().toISOString(),
56
59
  version: cloud.version,
57
60
  ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
58
61
  document: {
59
62
  id: cloud.documentId,
60
- title: cloud.title,
61
63
  defaultPrefix: cloud.defaultPrefix,
62
64
  },
63
65
  };
@@ -9,7 +9,7 @@ import YAML from "yaml";
9
9
  import { getConvexUrl } from "../config.js";
10
10
  import { api } from "../convex.js";
11
11
  import { findRequirementsFiles } from "../requirements/index.js";
12
- import { validateMetadata } from "../schema/index.js";
12
+ import { splitLeadingH1, validateMetadata } from "../schema/index.js";
13
13
  import { parseRequirementBlocksFromMarkdown, splitFrontmatter, stripFrontmatterBlock, } from "../schema/parser-core.js";
14
14
  /**
15
15
  * Read and classify one requirements file into a LocalDocument. A file whose
@@ -79,7 +79,11 @@ export function readLocalDocument(filePath) {
79
79
  catch (err) {
80
80
  return { filePath, documentId, title, parseError: err.message };
81
81
  }
82
- return { filePath, documentId, title, defaultPrefix, body };
82
+ // SYNC-TITLE-1: the body's leading H1 is the document's title. A legacy
83
+ // frontmatter title stands only when the body has none (SYNC-TITLE-1.1);
84
+ // with neither, the document is untitled (SYNC-TITLE-1.4).
85
+ const derivedTitle = splitLeadingH1(body).title ?? title;
86
+ return { filePath, documentId, title: derivedTitle, defaultPrefix, body };
83
87
  }
84
88
  /**
85
89
  * Build the local snapshot from the workspace, or from an explicit set of file
@@ -10,8 +10,6 @@ export function generateExampleRequirements(projectId = "local") {
10
10
  projectId: ${projectId}
11
11
  pulledAt: ${now}
12
12
  version: 1
13
- document:
14
- title: "Example Requirements"
15
13
  ---
16
14
 
17
15
  # Example Requirements
@@ -13,8 +13,6 @@ export function generateExampleRequirements(
13
13
  projectId: ${projectId}
14
14
  pulledAt: ${now}
15
15
  version: 1
16
- document:
17
- title: "Example Requirements"
18
16
  ---
19
17
 
20
18
  # Example Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popoverai/dotrequirements",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Requirements tracking CLI and test harness",
5
5
  "type": "module",
6
6
  "bin": {