@popoverai/dotrequirements 0.27.4 → 0.28.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.
Files changed (50) hide show
  1. package/README.md +24 -20
  2. package/dist/cli.js +34 -12
  3. package/dist/commands/aliases.d.ts +26 -0
  4. package/dist/commands/aliases.js +31 -0
  5. package/dist/commands/diff.d.ts +14 -0
  6. package/dist/commands/diff.js +62 -0
  7. package/dist/commands/init.js +5 -5
  8. package/dist/commands/link.d.ts +1 -1
  9. package/dist/commands/link.js +10 -7
  10. package/dist/commands/sync-common.d.ts +21 -0
  11. package/dist/commands/sync-common.js +24 -0
  12. package/dist/commands/sync.d.ts +26 -0
  13. package/dist/commands/sync.js +182 -0
  14. package/dist/convex.d.ts +1 -1
  15. package/dist/convex.js +2 -2
  16. package/dist/push/core.d.ts +18 -116
  17. package/dist/push/core.js +16 -267
  18. package/dist/push/index.d.ts +3 -3
  19. package/dist/push/index.js +4 -4
  20. package/dist/requirements/style-guide.js +3 -3
  21. package/dist/schema/schemas.js +1 -1
  22. package/dist/sync/compare.d.ts +21 -0
  23. package/dist/sync/compare.js +285 -0
  24. package/dist/sync/execute.d.ts +48 -0
  25. package/dist/sync/execute.js +186 -0
  26. package/dist/sync/index.d.ts +21 -0
  27. package/dist/sync/index.js +52 -0
  28. package/dist/sync/local-files.d.ts +26 -0
  29. package/dist/sync/local-files.js +91 -0
  30. package/dist/sync/plan.d.ts +39 -0
  31. package/dist/sync/plan.js +90 -0
  32. package/dist/sync/render.d.ts +17 -0
  33. package/dist/sync/render.js +46 -0
  34. package/dist/sync/segment.d.ts +40 -0
  35. package/dist/sync/segment.js +76 -0
  36. package/dist/sync/snapshot.d.ts +30 -0
  37. package/dist/sync/snapshot.js +118 -0
  38. package/dist/sync/types.d.ts +82 -0
  39. package/dist/sync/types.js +12 -0
  40. package/dist/templates/context-file-section.md +2 -1
  41. package/dist/templates/requirements-readme.js +3 -4
  42. package/dist/templates/requirements-readme.ts +3 -4
  43. package/dist/templates/skills/codebase-to-spec/SKILL.md +2 -2
  44. package/dist/utils/project-settings.d.ts +8 -2
  45. package/dist/utils/project-settings.js +47 -24
  46. package/package.json +1 -1
  47. package/dist/commands/pull.d.ts +0 -8
  48. package/dist/commands/pull.js +0 -230
  49. package/dist/commands/push.d.ts +0 -6
  50. package/dist/commands/push.js +0 -244
@@ -0,0 +1,91 @@
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.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { buildRequirementsFile } from "../schema/index.js";
9
+ import { extractFrontmatterBlock } from "../schema/parser-core.js";
10
+ /** Sanitize a document title into a filename stem. */
11
+ export function sanitizeFileName(title) {
12
+ return title
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "");
16
+ }
17
+ /**
18
+ * Choose the path a cloud document is written to. An existing file linked by
19
+ * document ID keeps its path (SYNC-DISCOVERY-2.0); a new document lands in
20
+ * `.requirements/`, disambiguated by numeric suffix on collision and falling
21
+ * back to the document ID when the title sanitizes to nothing
22
+ * (SYNC-WEB-CREATE-2).
23
+ */
24
+ export function resolveLocalPath(cloud, existingPath, usedPaths, requirementsDir) {
25
+ if (existingPath)
26
+ return existingPath;
27
+ const baseName = sanitizeFileName(cloud.title) || cloud.documentId;
28
+ let candidate = path.join(requirementsDir, `${baseName}.requirements.md`);
29
+ let suffix = 2;
30
+ while (usedPaths.has(candidate) || fs.existsSync(candidate)) {
31
+ candidate = path.join(requirementsDir, `${baseName}-${suffix}.requirements.md`);
32
+ suffix++;
33
+ }
34
+ return candidate;
35
+ }
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
+ const metadata = {
55
+ pulledAt: new Date().toISOString(),
56
+ version: cloud.version,
57
+ ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
58
+ document: {
59
+ id: cloud.documentId,
60
+ title: cloud.title,
61
+ defaultPrefix: cloud.defaultPrefix,
62
+ },
63
+ };
64
+ const content = buildRequirementsFile(metadata, cloud.body);
65
+ // SYNC-WRITE-1.0: skip the write when the body+identity are unchanged, so a
66
+ // no-op sync leaves the working tree clean. Compare ignoring the volatile
67
+ // pulledAt line, which would otherwise force a rewrite every run.
68
+ if (fs.existsSync(filePath)) {
69
+ const current = fs.readFileSync(filePath, "utf-8");
70
+ if (withoutPulledAt(current) === withoutPulledAt(content))
71
+ return false;
72
+ }
73
+ fs.writeFileSync(filePath, content, "utf-8");
74
+ return true;
75
+ }
76
+ /**
77
+ * Normalize the volatile pulledAt line for equality comparison — within the
78
+ * frontmatter only, so a body line that happens to start "pulledAt:" can never
79
+ * mask a genuine content change (which would silently skip the write).
80
+ */
81
+ function withoutPulledAt(fileContent) {
82
+ // CRLF-normalize first: extractFrontmatterBlock returns LF-normalized yaml,
83
+ // so the replace below must operate on LF content to find it.
84
+ const normalized = fileContent.replace(/\r\n/g, "\n");
85
+ const yaml = extractFrontmatterBlock(normalized);
86
+ if (yaml === undefined)
87
+ return normalized;
88
+ const normalizedYaml = yaml.replace(/^pulledAt: .*$/m, "pulledAt: <normalized>");
89
+ return normalized.replace(yaml, normalizedYaml);
90
+ }
91
+ //# sourceMappingURL=local-files.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Turning verdicts into actions under a sync mode (SYNC-MODE-1..4).
3
+ *
4
+ * The mode is direction × authority; the action table below is the whole
5
+ * policy layer over the comparator. Pure and table-driven so it is exhaustively
6
+ * testable.
7
+ */
8
+ import type { DocumentComparison, Verdict } from "./types.js";
9
+ export type SyncMode = "bare" | "cloud_contributes" | "repo_contributes" | "repo_wins" | "cloud_wins";
10
+ export interface ModeFlags {
11
+ cloudContributes?: boolean;
12
+ repoContributes?: boolean;
13
+ cloudWins?: boolean;
14
+ repoWins?: boolean;
15
+ }
16
+ /**
17
+ * Resolve raw flags into a mode, rejecting incoherent combinations
18
+ * (SYNC-MODE-3.2). Passing both contribute flags is the bare default spelled
19
+ * out (SYNC-MODE-2.3).
20
+ */
21
+ export declare function resolveMode(flags: ModeFlags): SyncMode;
22
+ /**
23
+ * What a sync does to one document.
24
+ * - cloud_write: save the repo's content to the cloud (create or overwrite)
25
+ * - local_write: write the cloud's content to the local file (create or overwrite)
26
+ * - cloud_delete / local_delete: remove the document on that side (wins modes only)
27
+ * - skip_conflict: a conflict left untouched, reported with its resolution
28
+ * - invalid: an unparseable local file, reported and skipped
29
+ * - skip: nothing to do
30
+ */
31
+ export type SyncAction = "cloud_write" | "local_write" | "cloud_delete" | "local_delete" | "skip_conflict" | "invalid" | "skip";
32
+ export declare function planAction(verdict: Verdict, mode: SyncMode): SyncAction;
33
+ export interface PlannedAction {
34
+ doc: DocumentComparison;
35
+ action: SyncAction;
36
+ }
37
+ /** Assign an action to every document under the given mode. */
38
+ export declare function buildPlan(documents: DocumentComparison[], mode: SyncMode): PlannedAction[];
39
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Turning verdicts into actions under a sync mode (SYNC-MODE-1..4).
3
+ *
4
+ * The mode is direction × authority; the action table below is the whole
5
+ * policy layer over the comparator. Pure and table-driven so it is exhaustively
6
+ * testable.
7
+ */
8
+ /**
9
+ * Resolve raw flags into a mode, rejecting incoherent combinations
10
+ * (SYNC-MODE-3.2). Passing both contribute flags is the bare default spelled
11
+ * out (SYNC-MODE-2.3).
12
+ */
13
+ export function resolveMode(flags) {
14
+ const wins = [flags.repoWins, flags.cloudWins].filter(Boolean).length;
15
+ const contributes = [flags.cloudContributes, flags.repoContributes].filter(Boolean).length;
16
+ if (wins > 1 || (wins > 0 && contributes > 0)) {
17
+ throw new Error("Authority must be a single side: combine at most one of --repo-wins or --cloud-wins, and not with a --contributes flag.");
18
+ }
19
+ if (flags.repoWins)
20
+ return "repo_wins";
21
+ if (flags.cloudWins)
22
+ return "cloud_wins";
23
+ if (flags.cloudContributes && !flags.repoContributes)
24
+ return "cloud_contributes";
25
+ if (flags.repoContributes && !flags.cloudContributes)
26
+ return "repo_contributes";
27
+ return "bare";
28
+ }
29
+ const TABLE = {
30
+ only_in_repo: {
31
+ bare: "cloud_write",
32
+ cloud_contributes: "skip",
33
+ repo_contributes: "cloud_write",
34
+ repo_wins: "cloud_write",
35
+ cloud_wins: "local_delete",
36
+ },
37
+ only_in_cloud: {
38
+ bare: "local_write",
39
+ cloud_contributes: "local_write",
40
+ repo_contributes: "skip",
41
+ repo_wins: "cloud_delete",
42
+ cloud_wins: "local_write",
43
+ },
44
+ additions_in_repo: {
45
+ bare: "cloud_write",
46
+ cloud_contributes: "skip",
47
+ repo_contributes: "cloud_write",
48
+ repo_wins: "cloud_write",
49
+ cloud_wins: "local_write",
50
+ },
51
+ additions_in_cloud: {
52
+ bare: "local_write",
53
+ cloud_contributes: "local_write",
54
+ repo_contributes: "skip",
55
+ repo_wins: "cloud_write",
56
+ cloud_wins: "local_write",
57
+ },
58
+ conflict: {
59
+ bare: "skip_conflict",
60
+ cloud_contributes: "skip_conflict",
61
+ repo_contributes: "skip_conflict",
62
+ repo_wins: "cloud_write",
63
+ cloud_wins: "local_write",
64
+ },
65
+ in_sync: {
66
+ bare: "skip",
67
+ cloud_contributes: "skip",
68
+ repo_contributes: "skip",
69
+ repo_wins: "skip",
70
+ cloud_wins: "skip",
71
+ },
72
+ invalid_file: {
73
+ bare: "invalid",
74
+ cloud_contributes: "invalid",
75
+ repo_contributes: "invalid",
76
+ repo_wins: "invalid",
77
+ cloud_wins: "invalid",
78
+ },
79
+ };
80
+ export function planAction(verdict, mode) {
81
+ return TABLE[verdict][mode];
82
+ }
83
+ /** Assign an action to every document under the given mode. */
84
+ export function buildPlan(documents, mode) {
85
+ return documents.map((doc) => ({
86
+ doc,
87
+ action: planAction(doc.verdict, mode),
88
+ }));
89
+ }
90
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Presentation helpers over the comparison result — shared by `dotreq diff`
3
+ * and `dotreq sync` so their vocabulary can't drift.
4
+ */
5
+ import type { DocumentComparison, UnitDetail } from "./types.js";
6
+ /** The stable token a user would pass as scope to act on this document: its
7
+ * file basename when local, else its cloud id, else its title. */
8
+ export declare function scopeToken(doc: DocumentComparison): string;
9
+ /** A human name for the document in listings. */
10
+ export declare function displayName(doc: DocumentComparison): string;
11
+ /** The copy-pasteable resolution command for a conflict (DIFF-5, SYNC-MODE-1.2). */
12
+ export declare function resolutionHint(doc: DocumentComparison): string;
13
+ /** One line per document: glyph, name, verdict. */
14
+ export declare function formatVerdictLine(doc: DocumentComparison): string;
15
+ /** Verbose, one-level-unfolded detail for a document (DIFF-2). */
16
+ export declare function formatUnitDetail(detail: UnitDetail): string;
17
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Presentation helpers over the comparison result — shared by `dotreq diff`
3
+ * and `dotreq sync` so their vocabulary can't drift.
4
+ */
5
+ import * as path from "node:path";
6
+ import { verdictLabel } from "./types.js";
7
+ /** A short glyph per verdict for scannable output. */
8
+ const GLYPH = {
9
+ in_sync: "✓",
10
+ additions_in_repo: "→",
11
+ additions_in_cloud: "←",
12
+ conflict: "!",
13
+ only_in_repo: "+",
14
+ only_in_cloud: "+",
15
+ invalid_file: "✗",
16
+ };
17
+ /** The stable token a user would pass as scope to act on this document: its
18
+ * file basename when local, else its cloud id, else its title. */
19
+ export function scopeToken(doc) {
20
+ if (doc.filePath)
21
+ return path.basename(doc.filePath);
22
+ return doc.documentId ?? doc.title;
23
+ }
24
+ /** A human name for the document in listings. */
25
+ export function displayName(doc) {
26
+ if (doc.filePath)
27
+ return path.basename(doc.filePath);
28
+ return `${doc.title} (cloud)`;
29
+ }
30
+ /** The copy-pasteable resolution command for a conflict (DIFF-5, SYNC-MODE-1.2). */
31
+ export function resolutionHint(doc) {
32
+ return `resolve with \`dotreq sync ${scopeToken(doc)} --repo-wins\` or \`--cloud-wins\``;
33
+ }
34
+ /** One line per document: glyph, name, verdict. */
35
+ export function formatVerdictLine(doc) {
36
+ return ` ${GLYPH[doc.verdict]} ${displayName(doc).padEnd(40)} ${verdictLabel(doc.verdict)}`;
37
+ }
38
+ /** Verbose, one-level-unfolded detail for a document (DIFF-2). */
39
+ export function formatUnitDetail(detail) {
40
+ const positions = detail.positions && detail.positions.length > 0
41
+ ? ` (${detail.note === "prose" ? "" : "positions "}${detail.positions.join(", ")})`
42
+ : "";
43
+ const label = detail.note === "prose" ? "prose" : detail.unit;
44
+ return ` ${label}: ${verdictLabel(detail.verdict)}${positions}`;
45
+ }
46
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Segment a document body into an ordered sequence of units — prose passages
3
+ * and requirement blocks, interleaved (the comparator's structural input).
4
+ *
5
+ * A requirement's identity is its key; a prose passage's identity is its
6
+ * normalized text (prose has no stable id, so a reworded passage reads as a
7
+ * removal-plus-addition — a conflict, per DIFF-4.3).
8
+ */
9
+ export type Unit = {
10
+ kind: "prose";
11
+ text: string;
12
+ } | {
13
+ kind: "requirement";
14
+ key: string;
15
+ blockContent: string;
16
+ };
17
+ /**
18
+ * Normalize prose for comparison: LF line endings, trailing whitespace
19
+ * stripped per line, runs of blank lines collapsed, ends trimmed. Two prose
20
+ * passages that differ only in incidental whitespace compare equal, so a
21
+ * whitespace-only reflow does not read as a conflict.
22
+ */
23
+ export declare function normalizeProse(text: string): string;
24
+ /**
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.
28
+ */
29
+ export declare function segmentBody(body: string): Unit[];
30
+ /**
31
+ * Flatten a requirement block into a map from position-path id (e.g. "AUTH-1",
32
+ * "AUTH-1.0", "AUTH-1.2.1") to a canonical label+content string.
33
+ *
34
+ * Because the id encodes the position, a criterion inserted mid-list shifts the
35
+ * ids of everything after it — so an insertion cannot subset-match the original
36
+ * (DIFF-4.2), while an appended criterion adds a new id without disturbing the
37
+ * others (DIFF-4.1).
38
+ */
39
+ export declare function requirementUnitMap(blockContent: string): Map<string, string>;
40
+ //# sourceMappingURL=segment.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Segment a document body into an ordered sequence of units — prose passages
3
+ * and requirement blocks, interleaved (the comparator's structural input).
4
+ *
5
+ * A requirement's identity is its key; a prose passage's identity is its
6
+ * normalized text (prose has no stable id, so a reworded passage reads as a
7
+ * removal-plus-addition — a conflict, per DIFF-4.3).
8
+ */
9
+ import { getAllRequirements, parseRequirementBlock } from "../schema/index.js";
10
+ /** Matches a fenced dotrequirements block; group 1 is its inner content. */
11
+ const FENCE = /```dotrequirements\n([\s\S]*?)```/gm;
12
+ /**
13
+ * Normalize prose for comparison: LF line endings, trailing whitespace
14
+ * stripped per line, runs of blank lines collapsed, ends trimmed. Two prose
15
+ * passages that differ only in incidental whitespace compare equal, so a
16
+ * whitespace-only reflow does not read as a conflict.
17
+ */
18
+ export function normalizeProse(text) {
19
+ return text
20
+ .replace(/\r\n/g, "\n")
21
+ .split("\n")
22
+ .map((line) => line.replace(/\s+$/, ""))
23
+ .join("\n")
24
+ .replace(/\n{3,}/g, "\n\n")
25
+ .trim();
26
+ }
27
+ /**
28
+ * Split a body into its ordered units. Prose between (and around) fences
29
+ * becomes prose units when non-empty after normalization; each fence becomes a
30
+ * requirement unit keyed by its first-line KEY.
31
+ */
32
+ export function segmentBody(body) {
33
+ const normalized = body.replace(/\r\n/g, "\n");
34
+ const units = [];
35
+ let lastIndex = 0;
36
+ FENCE.lastIndex = 0;
37
+ let match = FENCE.exec(normalized);
38
+ while (match !== null) {
39
+ const prose = normalizeProse(normalized.slice(lastIndex, match.index));
40
+ if (prose)
41
+ units.push({ kind: "prose", text: prose });
42
+ const blockContent = match[1].trim();
43
+ const keyMatch = blockContent.match(/^([\w-]+):/);
44
+ // A block whose first line isn't KEY: is malformed; parsing rejects it
45
+ // upstream, so segmentation never sees it in the compare path. Fall back to
46
+ // the leading text as a key so segmentation stays total regardless.
47
+ const key = (keyMatch ? keyMatch[1] : blockContent.slice(0, 24)).toUpperCase();
48
+ units.push({ kind: "requirement", key, blockContent });
49
+ lastIndex = FENCE.lastIndex;
50
+ match = FENCE.exec(normalized);
51
+ }
52
+ const tail = normalizeProse(normalized.slice(lastIndex));
53
+ if (tail)
54
+ units.push({ kind: "prose", text: tail });
55
+ return units;
56
+ }
57
+ /**
58
+ * Flatten a requirement block into a map from position-path id (e.g. "AUTH-1",
59
+ * "AUTH-1.0", "AUTH-1.2.1") to a canonical label+content string.
60
+ *
61
+ * Because the id encodes the position, a criterion inserted mid-list shifts the
62
+ * ids of everything after it — so an insertion cannot subset-match the original
63
+ * (DIFF-4.2), while an appended criterion adds a new id without disturbing the
64
+ * others (DIFF-4.1).
65
+ */
66
+ export function requirementUnitMap(blockContent) {
67
+ const key = blockContent.match(/^([\w-]+):/)?.[1] ?? "";
68
+ const tree = parseRequirementBlock(key, blockContent);
69
+ const map = new Map();
70
+ for (const node of getAllRequirements([tree])) {
71
+ // A NUL (\u0000) separates the label from content so a label change reads as a change.
72
+ map.set(node.id.toUpperCase(), `${node.label}\u0000${node.content}`);
73
+ }
74
+ return map;
75
+ }
76
+ //# sourceMappingURL=segment.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Snapshot acquisition for the comparator — kept separate from comparison so
3
+ * the comparator stays a pure function (compare.ts). The local half reads the
4
+ * workspace; the cloud half is one exportProjectForCli query.
5
+ */
6
+ import type { CloudSnapshot, LocalDocument, LocalSnapshot } from "./types.js";
7
+ /**
8
+ * Read and classify one requirements file into a LocalDocument. A file whose
9
+ * body has a structural error (duplicate key/position, orphaned position) is
10
+ * returned with `parseError` set — the comparator reports it as invalid_file
11
+ * (SYNC-FAIL-4) — while its frontmatter title, if readable, is kept for display.
12
+ */
13
+ export declare function readLocalDocument(filePath: string): LocalDocument;
14
+ /**
15
+ * Build the local snapshot from the workspace, or from an explicit set of file
16
+ * paths (scope). Files are always read fresh from disk.
17
+ */
18
+ export declare function acquireLocalSnapshot(workspaceRoot: string, filePaths?: string[]): Promise<LocalSnapshot>;
19
+ export interface CloudAuth {
20
+ /** projectSlug + projectSecret for full access, or a bare share token. */
21
+ projectSlug?: string;
22
+ projectSecret: string;
23
+ }
24
+ /**
25
+ * Build the cloud snapshot with one exportProjectForCli query. A share token is
26
+ * passed as the projectSecret with no slug (the read-only wrapper resolves the
27
+ * project from the token) — the read-only path never writes back.
28
+ */
29
+ export declare function acquireCloudSnapshot(auth: CloudAuth): Promise<CloudSnapshot>;
30
+ //# sourceMappingURL=snapshot.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Snapshot acquisition for the comparator — kept separate from comparison so
3
+ * the comparator stays a pure function (compare.ts). The local half reads the
4
+ * workspace; the cloud half is one exportProjectForCli query.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import { ConvexHttpClient } from "convex/browser";
8
+ import YAML from "yaml";
9
+ import { getConvexUrl } from "../config.js";
10
+ import { api } from "../convex.js";
11
+ import { findRequirementsFiles } from "../requirements/index.js";
12
+ import { validateMetadata } from "../schema/index.js";
13
+ import { parseRequirementBlocksFromMarkdown, splitFrontmatter, stripFrontmatterBlock, } from "../schema/parser-core.js";
14
+ /**
15
+ * Read and classify one requirements file into a LocalDocument. A file whose
16
+ * body has a structural error (duplicate key/position, orphaned position) is
17
+ * returned with `parseError` set — the comparator reports it as invalid_file
18
+ * (SYNC-FAIL-4) — while its frontmatter title, if readable, is kept for display.
19
+ */
20
+ export function readLocalDocument(filePath) {
21
+ let raw;
22
+ try {
23
+ raw = fs.readFileSync(filePath, "utf-8");
24
+ }
25
+ catch (err) {
26
+ return {
27
+ filePath,
28
+ parseError: `Could not read file: ${err.message}`,
29
+ };
30
+ }
31
+ // Frontmatter first, so a body-parse failure still yields id/title.
32
+ let documentId;
33
+ let title;
34
+ let defaultPrefix;
35
+ let frontmatterError;
36
+ const split = splitFrontmatter(raw);
37
+ if (split) {
38
+ // Pull id/title from the raw YAML before schema validation: an invalid
39
+ // frontmatter must not cost the file its identity, or its cloud
40
+ // counterpart re-pairs as "only_in_repo" and a sync mints a duplicate
41
+ // cloud document.
42
+ let rawMeta;
43
+ try {
44
+ rawMeta = YAML.parse(split.yaml);
45
+ }
46
+ catch (err) {
47
+ frontmatterError = `Invalid YAML frontmatter: ${err.message}`;
48
+ }
49
+ if (rawMeta && typeof rawMeta === "object" && !Array.isArray(rawMeta)) {
50
+ const rawDoc = rawMeta.document;
51
+ if (rawDoc && typeof rawDoc === "object" && !Array.isArray(rawDoc)) {
52
+ const d = rawDoc;
53
+ if (typeof d.id === "string")
54
+ documentId = d.id;
55
+ if (typeof d.title === "string")
56
+ title = d.title;
57
+ if (typeof d.defaultPrefix === "string")
58
+ defaultPrefix = d.defaultPrefix;
59
+ }
60
+ }
61
+ if (rawMeta !== undefined && !frontmatterError) {
62
+ try {
63
+ validateMetadata(rawMeta);
64
+ }
65
+ catch (err) {
66
+ // Schema-invalid frontmatter flags the file (invalid_file) instead of
67
+ // proceeding with half-metadata — it must never sync as if unlinked.
68
+ frontmatterError = `Invalid frontmatter: ${err.message}`;
69
+ }
70
+ }
71
+ }
72
+ if (frontmatterError) {
73
+ return { filePath, documentId, title, parseError: frontmatterError };
74
+ }
75
+ const body = stripFrontmatterBlock(raw);
76
+ try {
77
+ parseRequirementBlocksFromMarkdown(body);
78
+ }
79
+ catch (err) {
80
+ return { filePath, documentId, title, parseError: err.message };
81
+ }
82
+ return { filePath, documentId, title, defaultPrefix, body };
83
+ }
84
+ /**
85
+ * Build the local snapshot from the workspace, or from an explicit set of file
86
+ * paths (scope). Files are always read fresh from disk.
87
+ */
88
+ export async function acquireLocalSnapshot(workspaceRoot, filePaths) {
89
+ const paths = filePaths ?? (await findRequirementsFiles(workspaceRoot));
90
+ return { documents: paths.map(readLocalDocument) };
91
+ }
92
+ /**
93
+ * Build the cloud snapshot with one exportProjectForCli query. A share token is
94
+ * passed as the projectSecret with no slug (the read-only wrapper resolves the
95
+ * project from the token) — the read-only path never writes back.
96
+ */
97
+ export async function acquireCloudSnapshot(auth) {
98
+ const client = new ConvexHttpClient(getConvexUrl());
99
+ const projectAuth = auth.projectSlug
100
+ ? { projectSlug: auth.projectSlug, projectSecret: auth.projectSecret }
101
+ : { projectSecret: auth.projectSecret };
102
+ const result = (await client.query(api.documents.queries.exportProjectForCli, {
103
+ projectAuth,
104
+ }));
105
+ return {
106
+ projectName: result.projectName,
107
+ projectSlug: result.projectSlug,
108
+ documents: result.documents.map((d) => ({
109
+ documentId: d.documentId,
110
+ title: d.title,
111
+ defaultPrefix: d.defaultPrefix,
112
+ body: d.markdownContent,
113
+ version: d.version,
114
+ updatedAt: d.updatedAt,
115
+ })),
116
+ };
117
+ }
118
+ //# sourceMappingURL=snapshot.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared vocabulary for the repo/cloud comparator (DIFF-*, SYNC-*).
3
+ *
4
+ * One vocabulary, no translation layer: the verdict identifiers here are the
5
+ * words the CLI prints. `verdictLabel` only reshapes an identifier into its
6
+ * spaced form ("additions_in_repo" -> "additions in repo"); it never renames.
7
+ */
8
+ /**
9
+ * Per-document verdict from comparing the repo and the cloud (DIFF-1.0).
10
+ */
11
+ export type Verdict = "in_sync" | "additions_in_repo" | "additions_in_cloud" | "conflict" | "only_in_repo" | "only_in_cloud" | "invalid_file";
12
+ /** The user-facing spelling of a verdict — same words, spaced. */
13
+ export declare function verdictLabel(verdict: Verdict): string;
14
+ /**
15
+ * One document as it exists locally: a `.requirements.md` file. When the file
16
+ * cannot be parsed, `parseError` is set and the other content fields are absent
17
+ * — the comparator reports it as `invalid_file` (SYNC-FAIL-4).
18
+ */
19
+ export interface LocalDocument {
20
+ filePath: string;
21
+ /** document.id from frontmatter, when the file is linked to a cloud document. */
22
+ documentId?: string;
23
+ title?: string;
24
+ defaultPrefix?: string;
25
+ /** Frontmatter-stripped markdown body (the comparison unit, SYNC-ARCH-1). */
26
+ body?: string;
27
+ parseError?: string;
28
+ }
29
+ /**
30
+ * One published document as it exists in the cloud, from exportProjectForCli.
31
+ */
32
+ export interface CloudDocument {
33
+ documentId: string;
34
+ title: string;
35
+ defaultPrefix?: string;
36
+ /** Published markdownContent (SYNC-ARCH-1). */
37
+ body: string;
38
+ version: number;
39
+ updatedAt: number;
40
+ }
41
+ export interface LocalSnapshot {
42
+ documents: LocalDocument[];
43
+ }
44
+ export interface CloudSnapshot {
45
+ projectName: string;
46
+ projectSlug: string;
47
+ documents: CloudDocument[];
48
+ }
49
+ /**
50
+ * A single point of difference within a paired document, for verbose
51
+ * document-scope diff (DIFF-2). `verdict` reuses the document vocabulary at
52
+ * unit granularity — the verdicts are fractal.
53
+ */
54
+ export interface UnitDetail {
55
+ /** Requirement key, or a short prose descriptor. */
56
+ unit: string;
57
+ verdict: "additions_in_repo" | "additions_in_cloud" | "conflict";
58
+ /** Criterion positions new on one side, when the difference is criterion-level. */
59
+ positions?: string[];
60
+ note?: string;
61
+ }
62
+ /**
63
+ * The comparison of one document across the two surfaces. Identity for
64
+ * scoping/output comes from documentId / filePath / title directly.
65
+ */
66
+ export interface DocumentComparison {
67
+ verdict: Verdict;
68
+ /** File path when the document exists locally. */
69
+ filePath?: string;
70
+ /** Cloud document id when the document exists in the cloud. */
71
+ documentId?: string;
72
+ /** Display title (from whichever side has the document). */
73
+ title: string;
74
+ /** Parse error message when verdict is invalid_file. */
75
+ error?: string;
76
+ /** Unit-level breakdown, populated for verbose diff of non-in-sync documents. */
77
+ details?: UnitDetail[];
78
+ }
79
+ export interface ComparisonResult {
80
+ documents: DocumentComparison[];
81
+ }
82
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared vocabulary for the repo/cloud comparator (DIFF-*, SYNC-*).
3
+ *
4
+ * One vocabulary, no translation layer: the verdict identifiers here are the
5
+ * words the CLI prints. `verdictLabel` only reshapes an identifier into its
6
+ * spaced form ("additions_in_repo" -> "additions in repo"); it never renames.
7
+ */
8
+ /** The user-facing spelling of a verdict — same words, spaced. */
9
+ export function verdictLabel(verdict) {
10
+ return verdict.replace(/_/g, " ");
11
+ }
12
+ //# sourceMappingURL=types.js.map
@@ -57,7 +57,8 @@ test(requirement('REQ-ID.0'), () => { /* test specific criterion */ });
57
57
  **Authoring:**
58
58
  - `dotreq create-requirement-document [path]` - Print the template and style guide
59
59
  - `dotreq validate [glob]` - Check syntax (works offline)
60
- - `dotreq push [file]` - Sync to cloud (shows diff preview, then confirms)
60
+ - `dotreq diff [scope...]` - Show how the repo and the cloud differ (read-only)
61
+ - `dotreq sync [scope...]` - Reconcile the repo and the cloud (both contribute by default)
61
62
 
62
63
  **Review (judgment runs in the dispatched subagent):**
63
64
  - `dotreq style-check <file>` - Emits the style guide + content for the reviewer to judge (`--source cloud` for hosted review)