jg-plans 0.1.2 → 0.2.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 (48) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/api/README.md +74 -11
  4. package/cli/README.md +23 -5
  5. package/dist/cli/arguments.js +18 -3
  6. package/dist/cli/commands.js +76 -17
  7. package/dist/cli/types.d.ts +3 -1
  8. package/dist/documents/fingerprints.d.ts +19 -0
  9. package/dist/documents/fingerprints.js +56 -0
  10. package/dist/documents/index.d.ts +2 -1
  11. package/dist/documents/index.js +1 -0
  12. package/dist/documents/milestones.js +44 -33
  13. package/dist/documents/plan.js +33 -68
  14. package/dist/documents/types.d.ts +22 -0
  15. package/dist/documents/utilities.d.ts +16 -2
  16. package/dist/documents/utilities.js +105 -4
  17. package/dist/edits/index.d.ts +2 -0
  18. package/dist/edits/index.js +2 -1
  19. package/dist/edits/preparations.d.ts +30 -0
  20. package/dist/edits/preparations.js +159 -0
  21. package/dist/edits/types.d.ts +14 -0
  22. package/dist/edits/utilities.d.ts +1 -0
  23. package/dist/edits/utilities.js +9 -2
  24. package/dist/errors/constants.d.ts +3 -1
  25. package/dist/errors/constants.js +4 -0
  26. package/dist/files/documents.d.ts +46 -0
  27. package/dist/files/documents.js +176 -0
  28. package/dist/files/index.d.ts +3 -1
  29. package/dist/files/index.js +2 -0
  30. package/dist/files/locks.d.ts +16 -0
  31. package/dist/files/locks.js +127 -0
  32. package/dist/files/mutations.d.ts +7 -3
  33. package/dist/files/mutations.js +32 -83
  34. package/dist/files/types.d.ts +19 -5
  35. package/dist/files/utilities.d.ts +4 -4
  36. package/dist/files/utilities.js +24 -7
  37. package/dist/index.d.ts +10 -8
  38. package/dist/index.js +5 -4
  39. package/dist/protocol/index.d.ts +4 -4
  40. package/dist/protocol/index.js +2 -2
  41. package/dist/protocol/types.d.ts +63 -0
  42. package/dist/protocol/types.js +53 -1
  43. package/docs/getting-started.md +2 -2
  44. package/docs/integrations.md +14 -36
  45. package/errors/README.md +6 -4
  46. package/examples/library.mjs +34 -2
  47. package/package.json +4 -3
  48. package/protocol/0.1/README.md +8 -2
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { planErrors } from "../errors/index.js";
3
- import { diagnostic, sourceLines, sourceLocation } from "./utilities.js";
3
+ import { diagnostic, headingSections, readFrontmatter, sourceLocation } from "./utilities.js";
4
4
  const markerFrom = (line, prefix) => {
5
5
  const leading = line.text.length - line.text.trimStart().length;
6
6
  if (!line.text.slice(leading).startsWith(prefix))
@@ -22,48 +22,51 @@ const cells = (line) => line
22
22
  .replace(/^\||\|$/g, '')
23
23
  .split('|')
24
24
  .map((cell) => cell.trim());
25
- const readOverview = (lines) => {
25
+ const readOverview = (lines, sections) => {
26
26
  const rows = [];
27
+ const tables = [];
27
28
  let present = false;
28
29
  let inOverview = false;
29
- let inTable = false;
30
+ const headings = new Map(sections.map((section) => [section.heading.start, section.title]));
30
31
  for (let index = 0; index < lines.length; index++) {
31
32
  const line = lines[index];
32
33
  if (line.fenced)
33
34
  continue;
34
- const text = line.start === 0 ? line.text.replace(/^\uFEFF/, '') : line.text;
35
- if (/^ {0,3}##[ \t]+/.test(text)) {
36
- inOverview = /^ {0,3}## Overview[ \t]*$/.test(text);
37
- inTable = false;
38
- }
35
+ const start = line.start + (line.start === 0 && line.text.startsWith('\uFEFF') ? 1 : 0);
36
+ if (headings.has(start))
37
+ inOverview = headings.get(start) === 'Overview';
39
38
  if (!inOverview)
40
39
  continue;
41
40
  const columns = cells(line.text);
42
41
  if (columns.join('|') === 'Milestone|Name|Status') {
43
42
  const separator = cells(lines[index + 1]?.text ?? '');
44
- inTable = separator.length === 3 && separator.every((cell) => /^:?-{3,}:?$/.test(cell));
45
- if (inTable) {
43
+ if (!lines[index + 1]?.fenced &&
44
+ separator.length === 3 &&
45
+ separator.every((cell) => /^:?-{3,}:?$/.test(cell))) {
46
46
  present = true;
47
- index++;
47
+ const start = line.start;
48
+ let end = lines[++index].fullEnd;
49
+ while (index + 1 < lines.length) {
50
+ const row = lines[index + 1];
51
+ const values = cells(row.text);
52
+ if (row.fenced || values.length !== 3 || !/^\d+$/.test(values[0]))
53
+ break;
54
+ index++;
55
+ end = row.fullEnd;
56
+ const ordinal = Number(values[0]);
57
+ rows.push({
58
+ ordinal: Number.isSafeInteger(ordinal) && ordinal > 0 ? ordinal : null,
59
+ ordinalText: values[0],
60
+ name: values[1],
61
+ status: values[2],
62
+ location: { offset: row.start, line: row.number, column: 1 },
63
+ });
64
+ }
65
+ tables.push({ start, end });
48
66
  }
49
- continue;
50
- }
51
- if (!inTable)
52
- continue;
53
- if (columns.length !== 3 || !/^\d+$/.test(columns[0])) {
54
- inTable = false;
55
- continue;
56
67
  }
57
- const ordinal = Number(columns[0]);
58
- rows.push({
59
- ordinal: Number.isSafeInteger(ordinal) && ordinal > 0 ? ordinal : null,
60
- ordinalText: columns[0],
61
- name: columns[1],
62
- status: columns[2],
63
- location: { offset: line.start, line: line.number, column: 1 },
64
- });
65
68
  }
66
- return { present, rows };
69
+ return { present, rows, tables };
67
70
  };
68
71
  /**
69
72
  * Observe numbered milestones and their markers, ignoring fenced examples.
@@ -76,23 +79,30 @@ const readOverview = (lines) => {
76
79
  export const parseMilestones = (source) => {
77
80
  if (!z.string().safeParse(source).success)
78
81
  throw planErrors.create('invalidInput');
79
- const lines = sourceLines(source);
80
- const overview = readOverview(lines);
82
+ const observed = readFrontmatter(source, 'milestones');
83
+ const { lines } = observed;
84
+ const sections = headingSections(source, lines);
85
+ const overview = readOverview(lines, sections);
81
86
  const result = {
82
87
  source,
88
+ body: observed.body,
89
+ frontmatter: observed.frontmatter,
90
+ rawMetadata: observed.rawMetadata,
91
+ preamble: { start: 0, end: sections[0]?.heading.start ?? source.length },
92
+ sections,
93
+ overviewTables: overview.tables,
83
94
  milestones: [],
84
95
  overview: overview.rows,
85
96
  overviewPresent: overview.present,
86
- diagnostics: [],
97
+ diagnostics: observed.diagnostics,
87
98
  };
88
- const headings = lines.filter((line) => !line.fenced &&
89
- /^ {0,3}##[ \t]+/.test(line.start === 0 ? line.text.replace(/^\uFEFF/, '') : line.text));
99
+ const headings = sections.map((section) => lines[sourceLocation(lines, section.heading.start).line - 1]);
90
100
  const ordinals = new Set();
91
101
  const ids = new Set();
92
102
  let previous = 0;
93
103
  for (let index = 0; index < headings.length; index++) {
94
104
  const heading = headings[index];
95
- const match = /^ {0,3}##[ \t]+Milestone (-?\d+):[ \t]*(.*?)(?:[ \t]+#+)?[ \t]*$/.exec(heading.text.replace(/^\uFEFF/, ''));
105
+ const match = /^Milestone (-?\d+):[ \t]*(.*)$/s.exec(sections[index].title);
96
106
  if (!match)
97
107
  continue;
98
108
  const end = headings[index + 1]?.start ?? source.length;
@@ -114,6 +124,7 @@ export const parseMilestones = (source) => {
114
124
  status: statuses[0]?.value ?? null,
115
125
  id: identities[0]?.value ?? null,
116
126
  location,
127
+ headingEnd: heading.fullEnd,
117
128
  end,
118
129
  body: source.slice(heading.fullEnd, end),
119
130
  statusMarkers: statuses,
@@ -1,8 +1,8 @@
1
- import { isMap, isNode, isScalar, parseDocument } from 'yaml';
1
+ import { isMap, isNode, isScalar } from 'yaml';
2
2
  import { z } from 'zod';
3
3
  import { planErrors } from "../errors/index.js";
4
4
  import { PlanMetadataSchema } from "../protocol/index.js";
5
- import { diagnostic, sourceLines, sourceLocation } from "./utilities.js";
5
+ import { diagnostic, headingSections, readFrontmatter, sourceLocation } from "./utilities.js";
6
6
  /**
7
7
  * Observe a plan without requiring valid metadata. Source and body remain unchanged.
8
8
  * Unsupported YAML expansion retains source and a diagnostic, not a fabricated value.
@@ -14,80 +14,45 @@ import { diagnostic, sourceLines, sourceLocation } from "./utilities.js";
14
14
  export const parsePlan = (source) => {
15
15
  if (!z.string().safeParse(source).success)
16
16
  throw planErrors.create('invalidInput');
17
+ const observed = readFrontmatter(source, 'plan');
18
+ const sections = headingSections(source, observed.lines);
17
19
  const result = {
18
20
  source,
19
- body: source,
20
- frontmatter: null,
21
- rawMetadata: null,
21
+ body: observed.body,
22
+ frontmatter: observed.frontmatter,
23
+ rawMetadata: observed.rawMetadata,
22
24
  metadata: {},
23
- diagnostics: [],
25
+ preamble: { start: 0, end: sections[0]?.heading.start ?? source.length },
26
+ sections,
27
+ diagnostics: observed.diagnostics,
24
28
  };
25
- const lines = sourceLines(source);
26
- const first = lines[0];
27
- const bom = source.startsWith('\uFEFF') ? 1 : 0;
28
- if (!first || !/^---[ \t]*$/.test(first.text.slice(bom))) {
29
+ const { yaml: document, frontmatter, lines } = observed;
30
+ if (!frontmatter)
29
31
  result.diagnostics.push(diagnostic('plan', 'MISSING_FRONTMATTER', 'Preferred plan metadata is absent.'));
30
- }
31
- else {
32
- const closing = lines.slice(1).find((line) => /^---[ \t]*$/.test(line.text));
33
- result.frontmatter = {
34
- start: bom,
35
- contentStart: first.fullEnd,
36
- contentEnd: closing?.start ?? source.length,
37
- end: closing?.fullEnd ?? source.length,
38
- closed: Boolean(closing),
39
- };
40
- if (!closing)
41
- result.diagnostics.push(diagnostic('plan', 'UNTERMINATED_FRONTMATTER', 'The opening frontmatter has no closing delimiter.', sourceLocation(lines, bom)));
42
- else
43
- result.body = source.slice(closing.fullEnd);
44
- const yamlSource = source.slice(first.fullEnd, closing?.start ?? source.length);
45
- const document = parseDocument(yamlSource, { keepSourceTokens: true, logLevel: 'silent' });
46
- for (const issue of document.errors)
47
- result.diagnostics.push(diagnostic('plan', 'INVALID_YAML', `YAML could not be fully parsed (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0])));
48
- for (const issue of document.warnings)
49
- result.diagnostics.push(diagnostic('plan', 'YAML_WARNING', `YAML reader warning (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0]), 'warning'));
50
- try {
51
- const raw = document.toJS({ maxAliasCount: 100 });
52
- // JSON automation cannot represent recursive aliases, BigInt or nonfinite numbers.
53
- // Keep their original YAML and report unavailable expansion instead of changing values.
54
- JSON.stringify(raw, (_key, value) => {
55
- if (typeof value === 'number' &&
56
- (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
57
- throw planErrors.create('invalidInput');
58
- return value;
59
- });
60
- result.rawMetadata = raw;
61
- }
62
- catch {
63
- result.diagnostics.push(diagnostic('plan', 'YAML_VALUE_UNAVAILABLE', 'Some YAML values cannot be expanded into JSON-safe observations; the original source is retained.', sourceLocation(lines, first.fullEnd), 'warning'));
64
- }
65
- if (!isMap(document.contents)) {
66
- result.diagnostics.push(diagnostic('plan', 'METADATA_NOT_MAPPING', 'Preferred frontmatter is a mapping of metadata fields.', sourceLocation(lines, first.fullEnd)));
67
- }
68
- else {
69
- for (const key of Object.keys(PlanMetadataSchema.shape)) {
70
- const node = document.get(key, true);
71
- if (node === undefined)
72
- continue;
73
- const raw = result.rawMetadata;
74
- const value = raw !== null && typeof raw === 'object' && Object.hasOwn(raw, key)
75
- ? Reflect.get(raw, key)
76
- : isScalar(node)
77
- ? node.value
78
- : node;
79
- const parsed = PlanMetadataSchema.shape[key].safeParse(value);
80
- if (parsed.success)
81
- Object.assign(result.metadata, { [key]: parsed.data });
82
- else
83
- result.diagnostics.push(diagnostic('plan', 'INVALID_METADATA_FIELD', `Metadata field '${key}' has an incompatible value.`, sourceLocation(lines, first.fullEnd + (isNode(node) ? (node.range?.[0] ?? 0) : 0))));
84
- }
32
+ if (document && frontmatter && isMap(document.contents)) {
33
+ for (const key of Object.keys(PlanMetadataSchema.shape)) {
34
+ const node = document.get(key, true);
35
+ if (node === undefined)
36
+ continue;
37
+ const raw = result.rawMetadata;
38
+ const value = raw !== null && typeof raw === 'object' && Object.hasOwn(raw, key)
39
+ ? Reflect.get(raw, key)
40
+ : isScalar(node)
41
+ ? node.value
42
+ : node;
43
+ const parsed = PlanMetadataSchema.shape[key].safeParse(value);
44
+ if (parsed.success)
45
+ Object.assign(result.metadata, { [key]: parsed.data });
46
+ else
47
+ result.diagnostics.push(diagnostic('plan', 'INVALID_METADATA_FIELD', `Metadata field '${key}' has an incompatible value.`, sourceLocation(lines, frontmatter.contentStart + (isNode(node) ? (node.range?.[0] ?? 0) : 0))));
85
48
  }
86
49
  }
50
+ const invalid = new Set(result.diagnostics
51
+ .filter((item) => item.code === 'INVALID_METADATA_FIELD')
52
+ .map((item) => item.message));
87
53
  for (const key of Object.keys(PlanMetadataSchema.shape)) {
88
- const isObserved = result.diagnostics.some((item) => item.code === 'INVALID_METADATA_FIELD' &&
89
- item.message.startsWith(`Metadata field '${key}'`));
90
- if (!(key in result.metadata) && !isObserved)
54
+ if (!(key in result.metadata) &&
55
+ !invalid.has(`Metadata field '${key}' has an incompatible value.`))
91
56
  result.diagnostics.push(diagnostic('plan', 'MISSING_METADATA_FIELD', `Preferred metadata field '${key}' is absent.`));
92
57
  }
93
58
  return result;
@@ -1,4 +1,15 @@
1
1
  import type { IDiagnostic, IPlanMetadata, ISourceLocation } from '../protocol/index.ts';
2
+ /** Original-source UTF-16 offsets, with an exclusive end. */
3
+ export type ISourceSpan = {
4
+ start: number;
5
+ end: number;
6
+ };
7
+ /** One unfenced level-two heading and all text up to the next such heading. */
8
+ export type IHeadingSection = {
9
+ title: string;
10
+ heading: ISourceSpan;
11
+ body: ISourceSpan;
12
+ };
2
13
  /** Read models retain original text and partial observations; diagnostics carry ambiguity. */
3
14
  export type IFrontmatter = {
4
15
  start: number;
@@ -13,6 +24,8 @@ export type IParsedPlan = {
13
24
  frontmatter: IFrontmatter | null;
14
25
  rawMetadata: unknown;
15
26
  metadata: Partial<IPlanMetadata>;
27
+ preamble: ISourceSpan;
28
+ sections: IHeadingSection[];
16
29
  diagnostics: IDiagnostic[];
17
30
  };
18
31
  export type IMarker = {
@@ -30,6 +43,7 @@ export type IMilestone = {
30
43
  status: string | null;
31
44
  id: string | null;
32
45
  location: ISourceLocation;
46
+ headingEnd: number;
33
47
  end: number;
34
48
  body: string;
35
49
  statusMarkers: IMarker[];
@@ -44,6 +58,12 @@ export type IOverviewRow = {
44
58
  };
45
59
  export type IParsedMilestones = {
46
60
  source: string;
61
+ body: string;
62
+ frontmatter: IFrontmatter | null;
63
+ rawMetadata: unknown;
64
+ preamble: ISourceSpan;
65
+ sections: IHeadingSection[];
66
+ overviewTables: ISourceSpan[];
47
67
  milestones: IMilestone[];
48
68
  overview: IOverviewRow[];
49
69
  overviewPresent: boolean;
@@ -53,6 +73,8 @@ export type ICheckResult = {
53
73
  conforms: boolean;
54
74
  diagnostics: IDiagnostic[];
55
75
  };
76
+ /** A structural observation; consumers decide exclusions and authorization policy. */
77
+ export type { IPlanFingerprint } from '../protocol/index.ts';
56
78
  export type ISourceLine = {
57
79
  text: string;
58
80
  start: number;
@@ -1,7 +1,21 @@
1
+ import { parseDocument } from 'yaml';
1
2
  import type { IDiagnostic, ISourceLocation } from '../protocol/index.ts';
2
- import type { ISourceLine } from './types.ts';
3
+ import type { IFrontmatter, IHeadingSection, ISourceLine } from './types.ts';
3
4
  /** Preserve original offsets and line endings. Fence recognition derives from public jg-agent-hq. */
4
- export declare const sourceLines: (source: string) => ISourceLine[];
5
+ export declare const sourceLines: (source: string, frontmatterEnd?: number) => ISourceLine[];
5
6
  /** Binary lookup avoids rescanning the source for each diagnostic. */
6
7
  export declare const sourceLocation: (lines: ISourceLine[], offset: number) => ISourceLocation;
7
8
  export declare const diagnostic: (document: IDiagnostic["document"], code: string, message: string, location?: ISourceLocation, severity?: IDiagnostic["severity"]) => IDiagnostic;
9
+ type IFrontmatterRead = {
10
+ body: string;
11
+ frontmatter: IFrontmatter | null;
12
+ rawMetadata: unknown;
13
+ yaml: ReturnType<typeof parseDocument> | null;
14
+ lines: ISourceLine[];
15
+ diagnostics: IDiagnostic[];
16
+ };
17
+ /** Shared framing/YAML reader; preferred plan fields are validated by the plan parser alone. */
18
+ export declare const readFrontmatter: (source: string, name: IDiagnostic["document"]) => IFrontmatterRead;
19
+ /** Complete, ordered section spans; the prefix before the first heading is the preamble. */
20
+ export declare const headingSections: (source: string, lines: ISourceLine[]) => IHeadingSection[];
21
+ export {};
@@ -1,21 +1,27 @@
1
+ import { stringifyJsonDeterministically } from 'jg-utilities';
2
+ import { isMap, isScalar, parseDocument, visit } from 'yaml';
3
+ import { planErrors } from "../errors/index.js";
1
4
  /** Preserve original offsets and line endings. Fence recognition derives from public jg-agent-hq. */
2
- export const sourceLines = (source) => {
5
+ export const sourceLines = (source, frontmatterEnd = 0) => {
3
6
  const lines = [];
4
7
  let fence = null;
5
8
  for (const match of source.matchAll(/([^\r\n]*)(\r\n|\n|\r|$)/g)) {
6
9
  if (!match[0])
7
10
  continue;
8
11
  const text = match[1];
9
- const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(match.index === 0 ? text.replace(/^\uFEFF/, '') : text);
12
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/s.exec(match.index === 0 ? text.replace(/^\uFEFF/, '') : text);
10
13
  const line = {
11
14
  text,
12
15
  start: match.index,
13
16
  end: match.index + text.length,
14
17
  fullEnd: match.index + match[0].length,
15
18
  number: lines.length + 1,
16
- fenced: fence !== null,
19
+ fenced: match.index < frontmatterEnd || fence !== null,
17
20
  };
18
- if (fence) {
21
+ if (match.index < frontmatterEnd) {
22
+ // YAML text never opens a Markdown fence that could hide later body sections.
23
+ }
24
+ else if (fence) {
19
25
  if (marker &&
20
26
  marker[1][0] === fence.character &&
21
27
  marker[1].length >= fence.length &&
@@ -51,3 +57,98 @@ export const diagnostic = (document, code, message, location, severity = 'error'
51
57
  severity,
52
58
  ...(location ? { location } : {}),
53
59
  });
60
+ /** Shared framing/YAML reader; preferred plan fields are validated by the plan parser alone. */
61
+ export const readFrontmatter = (source, name) => {
62
+ const lines = sourceLines(source);
63
+ const result = {
64
+ body: source,
65
+ frontmatter: null,
66
+ rawMetadata: null,
67
+ yaml: null,
68
+ lines,
69
+ diagnostics: [],
70
+ };
71
+ const first = lines[0];
72
+ const bom = source.startsWith('\uFEFF') ? 1 : 0;
73
+ if (!first || !/^---[ \t]*$/.test(first.text.slice(bom)))
74
+ return result;
75
+ const closing = lines.slice(1).find((line) => /^---[ \t]*$/.test(line.text));
76
+ result.frontmatter = {
77
+ start: bom,
78
+ contentStart: first.fullEnd,
79
+ contentEnd: closing?.start ?? source.length,
80
+ end: closing?.fullEnd ?? source.length,
81
+ closed: Boolean(closing),
82
+ };
83
+ result.lines = sourceLines(source, result.frontmatter.end);
84
+ if (closing)
85
+ result.body = source.slice(closing.fullEnd);
86
+ else
87
+ result.diagnostics.push(diagnostic(name, 'UNTERMINATED_FRONTMATTER', 'The opening frontmatter has no closing delimiter.', sourceLocation(lines, bom)));
88
+ const document = parseDocument(source.slice(first.fullEnd, closing?.start ?? source.length), {
89
+ keepSourceTokens: true,
90
+ logLevel: 'silent',
91
+ });
92
+ result.yaml = document;
93
+ for (const issue of document.errors)
94
+ result.diagnostics.push(diagnostic(name, 'INVALID_YAML', `YAML could not be fully parsed (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0])));
95
+ for (const issue of document.warnings)
96
+ result.diagnostics.push(diagnostic(name, 'YAML_WARNING', `YAML reader warning (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0]), 'warning'));
97
+ try {
98
+ // YAML coerces non-string mapping keys during toJS; that would lose structural identity.
99
+ visit(document, {
100
+ Pair(_key, pair) {
101
+ if (!isScalar(pair.key) || typeof pair.key.value !== 'string')
102
+ throw planErrors.create('invalidInput');
103
+ },
104
+ });
105
+ const raw = document.toJS({ maxAliasCount: 100 });
106
+ JSON.stringify(raw, (_key, value) => {
107
+ if (typeof value === 'number' &&
108
+ (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
109
+ throw planErrors.create('invalidInput');
110
+ return value;
111
+ });
112
+ // Wrapping permits observable scalar/array YAML while rejecting lossy JSON expansion.
113
+ stringifyJsonDeterministically({ metadata: raw }, { strict: true });
114
+ result.rawMetadata = raw;
115
+ }
116
+ catch {
117
+ result.diagnostics.push(diagnostic(name, 'YAML_VALUE_UNAVAILABLE', 'Some YAML values cannot be expanded into JSON-safe observations; the original source is retained.', sourceLocation(lines, first.fullEnd), 'warning'));
118
+ }
119
+ if (!isMap(document.contents))
120
+ result.diagnostics.push(diagnostic(name, 'METADATA_NOT_MAPPING', 'Preferred frontmatter is a mapping of metadata fields.', sourceLocation(lines, first.fullEnd)));
121
+ return result;
122
+ };
123
+ /** Complete, ordered section spans; the prefix before the first heading is the preamble. */
124
+ export const headingSections = (source, lines) => {
125
+ const headings = lines.flatMap((line) => {
126
+ if (line.fenced)
127
+ return [];
128
+ const bom = line.start === 0 && line.text.startsWith('\uFEFF') ? 1 : 0;
129
+ const text = line.text.slice(bom);
130
+ const prefix = /^ {0,3}##[ \t]+/.exec(text);
131
+ if (!prefix)
132
+ return [];
133
+ // Linear scans avoid backtracking over long whitespace runs before a nonclosing suffix.
134
+ const start = prefix[0].length;
135
+ let end = text.length;
136
+ while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t'))
137
+ end--;
138
+ let hashes = end;
139
+ while (hashes > start && text[hashes - 1] === '#')
140
+ hashes--;
141
+ if (hashes < end &&
142
+ (hashes === start || text[hashes - 1] === ' ' || text[hashes - 1] === '\t')) {
143
+ end = hashes;
144
+ while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t'))
145
+ end--;
146
+ }
147
+ return [{ line, bom, title: text.slice(start, end) }];
148
+ });
149
+ return headings.map(({ line, bom, title }, index) => ({
150
+ title,
151
+ heading: { start: line.start + bom, end: line.fullEnd },
152
+ body: { start: line.fullEnd, end: headings[index + 1]?.line.start ?? source.length },
153
+ }));
154
+ };
@@ -1 +1,3 @@
1
1
  export { applyEdits, decodeSource, encodeSource, milestoneEdits, planEdits } from './utilities.ts';
2
+ export { preparePlanUpdate, preparePlanDocumentWrite } from './preparations.ts';
3
+ export type { IPreparedPlanEdit, IAssignedMilestoneId } from './types.ts';
@@ -1,2 +1,3 @@
1
- // Package-private editing primitives. Public consumers use createPlan/updatePlan.
1
+ // Byte-editing primitives stay package-private; consumers can prepare edits without filesystem I/O.
2
2
  export { applyEdits, decodeSource, encodeSource, milestoneEdits, planEdits } from "./utilities.js";
3
+ export { preparePlanUpdate, preparePlanDocumentWrite } from "./preparations.js";
@@ -0,0 +1,30 @@
1
+ import { type IPlanDocumentWrite, type IPlanUpdate } from '../protocol/index.ts';
2
+ import type { IPreparedPlanEdit } from './types.ts';
3
+ /** Package-private expectation guard, also used on original read bytes by filesystem adapters. */
4
+ export declare const assertSourceExpectation: (source: string | Uint8Array | null, expected: string | null | undefined) => void;
5
+ /**
6
+ * Prepare one guarded metadata/marker patch entirely in memory, without policy updates or I/O.
7
+ * @param source Original selected document text.
8
+ * @param patch Explicit narrow patch and optional observed exact-byte SHA-256.
9
+ * @returns Prepared source, digest, diagnostics, and whether bytes would change.
10
+ * @throws
11
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
12
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
13
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
14
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
15
+ */
16
+ export declare const preparePlanUpdate: (source: string, patch: IPlanUpdate) => IPreparedPlanEdit;
17
+ /**
18
+ * Prepare guarded body/metadata replacement or explicit milestone source/ID assignment in memory.
19
+ * A null source is accepted only for explicit absent milestone creation. No timestamps, revisions,
20
+ * identities, or completion are inferred; missing IDs are assigned only when requested.
21
+ * @param source Original selected document text, or null for absent milestones.
22
+ * @param input Explicit document replacement and required source expectation.
23
+ * @returns The exact source to persist once, its digest, diagnostics, and generated IDs.
24
+ * @throws
25
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
26
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
27
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
28
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
29
+ */
30
+ export declare const preparePlanDocumentWrite: (source: string | null, input: IPlanDocumentWrite) => IPreparedPlanEdit;