jg-plans 0.1.0 → 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 +20 -0
  2. package/README.md +8 -8
  3. package/api/README.md +75 -12
  4. package/cli/README.md +24 -6
  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 +4 -4
  44. package/docs/integrations.md +14 -14
  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
@@ -0,0 +1,159 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hashValue } from 'jg-crypto';
3
+ import { parseMilestones, parsePlan } from "../documents/index.js";
4
+ import { planErrors } from "../errors/index.js";
5
+ import { PlanDocumentWriteSchema, PlanUpdateSchema, } from "../protocol/index.js";
6
+ import { applyEdits, decodeSource, encodeSource, lineEnding, milestoneEdits, planEdits, } from "./utilities.js";
7
+ /** Package-private expectation guard, also used on original read bytes by filesystem adapters. */
8
+ export const assertSourceExpectation = (source, expected) => {
9
+ if (expected === undefined)
10
+ return;
11
+ const actual = source === null ? null : hashValue(typeof source === 'string' ? encodeSource(source) : source);
12
+ if (actual !== expected)
13
+ throw planErrors.create('sourceChanged');
14
+ };
15
+ const prepared = (before, source, diagnostics, assignedMilestoneIds = []) => ({
16
+ source,
17
+ changed: before !== source,
18
+ sha256: hashValue(encodeSource(source)),
19
+ diagnostics,
20
+ assignedMilestoneIds,
21
+ });
22
+ const validateSet = (observation, set) => {
23
+ if (observation.diagnostics.some((item) => item.code === 'INVALID_YAML' || item.code === 'UNTERMINATED_FRONTMATTER'))
24
+ throw planErrors.create('unsafeEdit', { data: { reason: 'invalid-serialized-frontmatter' } });
25
+ for (const [key, value] of Object.entries(set))
26
+ if (!Object.is(observation.metadata[key], value))
27
+ throw planErrors.create('unsafeEdit', {
28
+ data: { reason: 'unsupported-serialized-value', field: key },
29
+ });
30
+ };
31
+ /**
32
+ * Prepare one guarded metadata/marker patch entirely in memory, without policy updates or I/O.
33
+ * @param source Original selected document text.
34
+ * @param patch Explicit narrow patch and optional observed exact-byte SHA-256.
35
+ * @returns Prepared source, digest, diagnostics, and whether bytes would change.
36
+ * @throws
37
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
38
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
39
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
40
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
41
+ */
42
+ export const preparePlanUpdate = (source, patch) => {
43
+ const validated = PlanUpdateSchema.safeParse(patch);
44
+ if (typeof source !== 'string' || !validated.success)
45
+ throw planErrors.create('invalidInput');
46
+ const request = validated.data;
47
+ const bytes = encodeSource(source);
48
+ assertSourceExpectation(source, request.expectedSha256);
49
+ const edits = request.document === 'plan' ? planEdits(source, request) : milestoneEdits(source, request);
50
+ const after = decodeSource(applyEdits(bytes, source, edits));
51
+ const observation = request.document === 'plan' ? parsePlan(after) : parseMilestones(after);
52
+ if (request.document === 'plan')
53
+ validateSet(observation, request.set);
54
+ return prepared(source, after, observation.diagnostics);
55
+ };
56
+ const assignMilestoneIds = (source) => {
57
+ const observation = parseMilestones(source);
58
+ if (observation.frontmatter && !observation.frontmatter.closed)
59
+ throw planErrors.create('unsafeEdit', { data: { reason: 'unterminated-frontmatter' } });
60
+ const ambiguous = new Set([
61
+ 'INVALID_MILESTONE_ID',
62
+ 'DUPLICATE_ID_MARKER',
63
+ 'DUPLICATE_ID',
64
+ 'DUPLICATE_ORDINAL',
65
+ 'INVALID_ORDINAL',
66
+ ]);
67
+ if (observation.diagnostics.some((item) => ambiguous.has(item.code)))
68
+ throw planErrors.create('unsafeEdit', { data: { reason: 'ambiguous-milestone-identities' } });
69
+ const ids = new Set(observation.milestones.flatMap((entry) => (entry.id ? [entry.id.toLowerCase()] : [])));
70
+ const assigned = [];
71
+ const changes = [];
72
+ for (const entry of observation.milestones) {
73
+ if (entry.id !== null)
74
+ continue;
75
+ const status = entry.statusMarkers[0];
76
+ if (!status ||
77
+ !status.value ||
78
+ entry.statusMarkers.length !== 1 ||
79
+ source.slice(entry.headingEnd, status.lineStart).trim())
80
+ throw planErrors.create('unsafeEdit', { data: { reason: 'noncanonical-id-insertion' } });
81
+ let id;
82
+ do {
83
+ id = randomUUID();
84
+ } while (ids.has(id.toLowerCase()));
85
+ ids.add(id.toLowerCase());
86
+ assigned.push({ ordinal: entry.ordinal, id });
87
+ changes.push({ select: { ordinal: entry.ordinal }, set: { id } });
88
+ }
89
+ if (!changes.length)
90
+ return { source, assigned };
91
+ const after = decodeSource(applyEdits(encodeSource(source), source, milestoneEdits(source, { document: 'milestones', changes })));
92
+ return { source: after, assigned };
93
+ };
94
+ /**
95
+ * Prepare guarded body/metadata replacement or explicit milestone source/ID assignment in memory.
96
+ * A null source is accepted only for explicit absent milestone creation. No timestamps, revisions,
97
+ * identities, or completion are inferred; missing IDs are assigned only when requested.
98
+ * @param source Original selected document text, or null for absent milestones.
99
+ * @param input Explicit document replacement and required source expectation.
100
+ * @returns The exact source to persist once, its digest, diagnostics, and generated IDs.
101
+ * @throws
102
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
103
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
104
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
105
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
106
+ */
107
+ export const preparePlanDocumentWrite = (source, input) => {
108
+ const validated = PlanDocumentWriteSchema.safeParse(input);
109
+ if ((source !== null && typeof source !== 'string') || !validated.success)
110
+ throw planErrors.create('invalidInput');
111
+ const request = validated.data;
112
+ if (request.document === 'plan' && source === null)
113
+ throw planErrors.create('invalidInput');
114
+ if (source !== null)
115
+ encodeSource(source);
116
+ assertSourceExpectation(source, request.expectedSha256);
117
+ if (request.document === 'milestones') {
118
+ encodeSource(request.source);
119
+ const assigned = request.assignMissingIds
120
+ ? assignMilestoneIds(request.source)
121
+ : { source: request.source, assigned: [] };
122
+ return prepared(source, assigned.source, parseMilestones(assigned.source).diagnostics, assigned.assigned);
123
+ }
124
+ const before = source;
125
+ const observation = parsePlan(before);
126
+ if (observation.frontmatter && !observation.frontmatter.closed)
127
+ throw planErrors.create('unsafeEdit', { data: { reason: 'unterminated-frontmatter' } });
128
+ encodeSource(request.body);
129
+ const bom = before.startsWith('\uFEFF') ? 1 : 0;
130
+ const bodyStart = observation.frontmatter?.end ?? bom;
131
+ // A metadata-free reader includes the encoding marker in body; never duplicate that marker.
132
+ const body = !observation.frontmatter && bom && request.body.startsWith('\uFEFF')
133
+ ? request.body.slice(1)
134
+ : request.body;
135
+ const eol = lineEnding(before);
136
+ const edits = request.set ? planEdits(before, { document: 'plan', set: request.set }) : [];
137
+ const insertion = !observation.frontmatter
138
+ ? edits.findIndex((edit) => edit.start === bodyStart && edit.end === bodyStart)
139
+ : -1;
140
+ let replacement = body;
141
+ if (insertion >= 0) {
142
+ const prefix = edits.splice(insertion, 1)[0].replacement;
143
+ replacement = prefix + (body && !/[\r\n]$/.test(prefix) ? eol : '') + body;
144
+ }
145
+ else if (observation.frontmatter &&
146
+ body &&
147
+ bodyStart === before.length &&
148
+ !/[\r\n]$/.test(before)) {
149
+ replacement = eol + body;
150
+ }
151
+ edits.push({ start: bodyStart, end: before.length, replacement });
152
+ const after = decodeSource(applyEdits(encodeSource(before), before, edits));
153
+ const result = parsePlan(after);
154
+ if (!observation.frontmatter && !request.set && result.frontmatter)
155
+ throw planErrors.create('unsafeEdit', { data: { reason: 'body-would-create-frontmatter' } });
156
+ if (request.set)
157
+ validateSet(result, request.set);
158
+ return prepared(before, after, result.diagnostics);
159
+ };
@@ -1,6 +1,20 @@
1
+ import type { IDiagnostic } from '../protocol/index.ts';
1
2
  /** Internal UTF-16 source spans, converted to original byte offsets before replacement. */
2
3
  export type ITextEdit = {
3
4
  start: number;
4
5
  end: number;
5
6
  replacement: string;
6
7
  };
8
+ /** IDs generated only by an explicit missing-ID assignment request. */
9
+ export type IAssignedMilestoneId = {
10
+ ordinal: number;
11
+ id: string;
12
+ };
13
+ /** In-memory preparation performs no filesystem operation or implicit policy update. */
14
+ export type IPreparedPlanEdit = {
15
+ source: string;
16
+ changed: boolean;
17
+ sha256: string;
18
+ diagnostics: IDiagnostic[];
19
+ assignedMilestoneIds: IAssignedMilestoneId[];
20
+ };
@@ -8,6 +8,7 @@ export declare const decodeSource: (bytes: Uint8Array) => string;
8
8
  * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
9
9
  */
10
10
  export declare const encodeSource: (source: string) => Buffer;
11
+ export declare const lineEnding: (source: string) => string;
11
12
  /**
12
13
  * Prepare supported metadata edits without changing unrelated YAML or Markdown.
13
14
  * @throws
@@ -2,7 +2,14 @@ import { isMap, isScalar, parseDocument, Scalar, stringify } from 'yaml';
2
2
  import { parseMilestones, parsePlan, sourceLines } from "../documents/index.js";
3
3
  import { planErrors } from "../errors/index.js";
4
4
  /** Decode strictly while retaining an existing BOM character. */
5
- export const decodeSource = (bytes) => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
5
+ export const decodeSource = (bytes) => {
6
+ try {
7
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
8
+ }
9
+ catch {
10
+ throw planErrors.create('unsupportedEncoding');
11
+ }
12
+ };
6
13
  /**
7
14
  * Reject unpaired surrogates rather than letting UTF-8 encoding replace them.
8
15
  * @throws
@@ -13,7 +20,7 @@ export const encodeSource = (source) => {
13
20
  throw planErrors.create('unsupportedEncoding');
14
21
  return Buffer.from(source, 'utf8');
15
22
  };
16
- const lineEnding = (source) => /\r\n|\n|\r/.exec(source)?.[0] ?? '\n';
23
+ export const lineEnding = (source) => /\r\n|\n|\r/.exec(source)?.[0] ?? '\n';
17
24
  const scalarText = (value, style) => {
18
25
  if (typeof value === 'number')
19
26
  return String(value);
@@ -7,5 +7,7 @@ export declare const ERRORS: {
7
7
  readonly DESTINATION_EXISTS: "JG_PLANS_DESTINATION_EXISTS";
8
8
  readonly SOURCE_CHANGED: "JG_PLANS_SOURCE_CHANGED";
9
9
  readonly WRITE_FAILED: "JG_PLANS_WRITE_FAILED";
10
+ readonly LOCK_BUSY: "JG_PLANS_LOCK_BUSY";
11
+ readonly LOCK_FAILED: "JG_PLANS_LOCK_FAILED";
10
12
  };
11
- export declare const planErrors: import("jg-exception").IErrorCatalog<"invalidInput" | "readFailed" | "unsupportedEncoding" | "unsafeEdit" | "destinationExists" | "sourceChanged" | "writeFailed">;
13
+ export declare const planErrors: import("jg-exception").IErrorCatalog<"invalidInput" | "readFailed" | "unsupportedEncoding" | "unsafeEdit" | "destinationExists" | "sourceChanged" | "writeFailed" | "lockBusy" | "lockFailed">;
@@ -8,6 +8,8 @@ export const ERRORS = {
8
8
  DESTINATION_EXISTS: 'JG_PLANS_DESTINATION_EXISTS',
9
9
  SOURCE_CHANGED: 'JG_PLANS_SOURCE_CHANGED',
10
10
  WRITE_FAILED: 'JG_PLANS_WRITE_FAILED',
11
+ LOCK_BUSY: 'JG_PLANS_LOCK_BUSY',
12
+ LOCK_FAILED: 'JG_PLANS_LOCK_FAILED',
11
13
  };
12
14
  // Underlying errors may contain user paths or input snippets. Stable public messages
13
15
  // omit that text; causes retain diagnostics, and safe details are explicit data.
@@ -31,4 +33,6 @@ export const planErrors = defineErrorCatalog({
31
33
  message: 'The document changed before replacement',
32
34
  },
33
35
  writeFailed: { code: ERRORS.WRITE_FAILED, message: 'The plan document could not be written' },
36
+ lockBusy: { code: ERRORS.LOCK_BUSY, message: 'The plan document is locked' },
37
+ lockFailed: { code: ERRORS.LOCK_FAILED, message: 'The plan document lock operation failed' },
34
38
  });
@@ -0,0 +1,46 @@
1
+ import { type IPreparedPlanEdit } from '../edits/index.ts';
2
+ import { type IPlanDocumentWrite, type IReadOptions, type IRemoveMilestones } from '../protocol/index.ts';
3
+ import type { IPlanDocumentWriteResult, IRemoveMilestonesResult } from './types.ts';
4
+ export type IOwnedFile = {
5
+ path: string;
6
+ dev: bigint;
7
+ ino: bigint;
8
+ bytes: Buffer;
9
+ };
10
+ /** Exclusive creation shared with createPlan; ownership is recorded before any write. */
11
+ export declare const writeExclusive: (root: string, path: string, bytes: Buffer, owned: IOwnedFile[]) => Promise<void>;
12
+ /** Package-private validation shared by all existing-directory mutations. */
13
+ export declare const validateMutationOptions: (directory: string, options: IReadOptions) => void;
14
+ /** Read exact bytes and guard them before decoding or preparing any new content. */
15
+ export declare const readMutationSource: (directory: string, document: "plan" | "milestones", options: IReadOptions, expected: string | null | undefined) => Promise<Buffer | null>;
16
+ /** Persist one prepared result once; null expectations use exclusive creation. */
17
+ export declare const persistPrepared: (directory: string, document: "plan" | "milestones", before: Buffer | null, prepared: IPreparedPlanEdit) => Promise<IPlanDocumentWriteResult>;
18
+ /**
19
+ * Guard and replace a plan body/core metadata or complete milestone document under its shared lock.
20
+ * Only the selected document is read; unrelated malformed documents do not block an operation.
21
+ * Expected null means absent milestones, never overwriting an existing file. No policy is inferred.
22
+ * @throws
23
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
24
+ * - JG_PLANS_READ_FAILED: The selected document could not be read
25
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
26
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
27
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
28
+ * - JG_PLANS_WRITE_FAILED: The plan document could not be written
29
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
30
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
31
+ */
32
+ export declare const writePlanDocument: (directory: string, input: IPlanDocumentWrite, options?: IReadOptions) => Promise<IPlanDocumentWriteResult>;
33
+ /**
34
+ * Remove only milestones.md with an explicit exact-byte expectation under its shared lock.
35
+ * Missing or changed files fail. No plan, directory, or history is removed. A final check detects
36
+ * observed external edits; nonparticipating writers still have a check-to-unlink race.
37
+ * @throws
38
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
39
+ * - JG_PLANS_READ_FAILED: The selected document could not be read
40
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
41
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
42
+ * - JG_PLANS_WRITE_FAILED: The plan document could not be written
43
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
44
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
45
+ */
46
+ export declare const removeMilestones: (directory: string, input: IRemoveMilestones, options?: IReadOptions) => Promise<IRemoveMilestonesResult>;
@@ -0,0 +1,176 @@
1
+ import { lstat, open, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { getErrorCode } from 'jg-exception';
4
+ import { assertNoLinksBelow, readBoundedFile, writeFileAtomicIfUnchanged } from 'jg-fs';
5
+ import { z } from 'zod';
6
+ import { decodeSource, encodeSource, preparePlanDocumentWrite, } from "../edits/index.js";
7
+ import { assertSourceExpectation } from "../edits/preparations.js";
8
+ import { planErrors } from "../errors/index.js";
9
+ import { DEFAULT_MAX_BYTES, PlanDocumentWriteSchema, ReadOptionsSchema, RemoveMilestonesSchema, } from "../protocol/index.js";
10
+ import { withPlanDocumentLocks } from "./locks.js";
11
+ /** Exclusive creation shared with createPlan; ownership is recorded before any write. */
12
+ export const writeExclusive = async (root, path, bytes, owned) => {
13
+ await assertNoLinksBelow(root, path);
14
+ const handle = await open(path, 'wx');
15
+ try {
16
+ const stat = await handle.stat({ bigint: true });
17
+ owned.push({ path, dev: stat.dev, ino: stat.ino, bytes });
18
+ await handle.writeFile(bytes);
19
+ await handle.sync();
20
+ }
21
+ finally {
22
+ await handle.close();
23
+ }
24
+ };
25
+ /** Package-private validation shared by all existing-directory mutations. */
26
+ export const validateMutationOptions = (directory, options) => {
27
+ if (!z
28
+ .string()
29
+ .min(1)
30
+ .refine((value) => !value.includes('\0'))
31
+ .safeParse(directory).success ||
32
+ !ReadOptionsSchema.safeParse(options).success)
33
+ throw planErrors.create('invalidInput');
34
+ };
35
+ /** Read exact bytes and guard them before decoding or preparing any new content. */
36
+ export const readMutationSource = async (directory, document, options, expected) => {
37
+ const path = join(directory, `${document}.md`);
38
+ let before;
39
+ try {
40
+ await assertNoLinksBelow(directory, path);
41
+ before = await readBoundedFile(path, options.maxBytes ?? DEFAULT_MAX_BYTES);
42
+ }
43
+ catch (cause) {
44
+ const reason = getErrorCode(cause);
45
+ if (expected !== undefined && (reason === 'NON_EXISTENT_FILE' || reason === 'ENOENT'))
46
+ before = null;
47
+ else
48
+ throw planErrors.create('readFailed', { cause, ...(reason ? { data: { reason } } : {}) });
49
+ }
50
+ assertSourceExpectation(before, expected);
51
+ return before;
52
+ };
53
+ /** Persist one prepared result once; null expectations use exclusive creation. */
54
+ export const persistPrepared = async (directory, document, before, prepared) => {
55
+ if (prepared.changed) {
56
+ try {
57
+ const path = join(directory, `${document}.md`);
58
+ await assertNoLinksBelow(directory, path);
59
+ if (before === null) {
60
+ const owned = [];
61
+ try {
62
+ await writeExclusive(directory, path, encodeSource(prepared.source), owned);
63
+ }
64
+ catch (cause) {
65
+ const cleanupFailedPaths = [];
66
+ for (const file of owned) {
67
+ try {
68
+ const stat = await lstat(file.path, { bigint: true });
69
+ const bytes = await readBoundedFile(file.path, Math.max(1, file.bytes.length));
70
+ if (!stat.isFile() ||
71
+ stat.dev !== file.dev ||
72
+ stat.ino !== file.ino ||
73
+ !bytes.equals(file.bytes.subarray(0, bytes.length)))
74
+ cleanupFailedPaths.push(file.path);
75
+ else
76
+ await unlink(file.path);
77
+ }
78
+ catch (cleanupCause) {
79
+ if (getErrorCode(cleanupCause) !== 'ENOENT' &&
80
+ getErrorCode(cleanupCause) !== 'NON_EXISTENT_FILE')
81
+ cleanupFailedPaths.push(file.path);
82
+ }
83
+ }
84
+ if (getErrorCode(cause) === 'EEXIST')
85
+ throw planErrors.create('sourceChanged', { cause });
86
+ throw planErrors.create('writeFailed', {
87
+ cause,
88
+ ...(cleanupFailedPaths.length ? { data: { cleanupFailedPaths } } : {}),
89
+ });
90
+ }
91
+ }
92
+ else
93
+ await writeFileAtomicIfUnchanged(path, encodeSource(prepared.source), before);
94
+ }
95
+ catch (cause) {
96
+ if (getErrorCode(cause)?.startsWith('JG_PLANS_'))
97
+ throw cause;
98
+ if (getErrorCode(cause) === 'FILE_CHANGED')
99
+ throw planErrors.create('sourceChanged', { cause });
100
+ throw planErrors.create('writeFailed', { cause });
101
+ }
102
+ }
103
+ return {
104
+ directory,
105
+ document,
106
+ changed: prepared.changed,
107
+ sha256: prepared.sha256,
108
+ diagnostics: prepared.diagnostics,
109
+ assignedMilestoneIds: prepared.assignedMilestoneIds,
110
+ };
111
+ };
112
+ /**
113
+ * Guard and replace a plan body/core metadata or complete milestone document under its shared lock.
114
+ * Only the selected document is read; unrelated malformed documents do not block an operation.
115
+ * Expected null means absent milestones, never overwriting an existing file. No policy is inferred.
116
+ * @throws
117
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
118
+ * - JG_PLANS_READ_FAILED: The selected document could not be read
119
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
120
+ * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
121
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
122
+ * - JG_PLANS_WRITE_FAILED: The plan document could not be written
123
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
124
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
125
+ */
126
+ export const writePlanDocument = async (directory, input, options = {}) => {
127
+ validateMutationOptions(directory, options);
128
+ const parsed = PlanDocumentWriteSchema.safeParse(input);
129
+ if (!parsed.success)
130
+ throw planErrors.create('invalidInput');
131
+ const request = parsed.data;
132
+ return withPlanDocumentLocks(directory, [request.document], async (selected) => {
133
+ const before = await readMutationSource(selected, request.document, options, request.expectedSha256);
134
+ const prepared = preparePlanDocumentWrite(before === null ? null : decodeSource(before), request);
135
+ return persistPrepared(selected, request.document, before, prepared);
136
+ });
137
+ };
138
+ /**
139
+ * Remove only milestones.md with an explicit exact-byte expectation under its shared lock.
140
+ * Missing or changed files fail. No plan, directory, or history is removed. A final check detects
141
+ * observed external edits; nonparticipating writers still have a check-to-unlink race.
142
+ * @throws
143
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
144
+ * - JG_PLANS_READ_FAILED: The selected document could not be read
145
+ * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
146
+ * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
147
+ * - JG_PLANS_WRITE_FAILED: The plan document could not be written
148
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
149
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
150
+ */
151
+ export const removeMilestones = async (directory, input, options = {}) => {
152
+ validateMutationOptions(directory, options);
153
+ const parsed = RemoveMilestonesSchema.safeParse(input);
154
+ if (!parsed.success)
155
+ throw planErrors.create('invalidInput');
156
+ return withPlanDocumentLocks(directory, ['milestones'], async (selected) => {
157
+ const before = await readMutationSource(selected, 'milestones', options, parsed.data.expectedSha256);
158
+ decodeSource(before);
159
+ await readMutationSource(selected, 'milestones', options, parsed.data.expectedSha256);
160
+ try {
161
+ await unlink(join(selected, 'milestones.md'));
162
+ }
163
+ catch (cause) {
164
+ throw planErrors.create(getErrorCode(cause) === 'ENOENT' ? 'sourceChanged' : 'writeFailed', {
165
+ cause,
166
+ });
167
+ }
168
+ return {
169
+ directory: selected,
170
+ document: 'milestones',
171
+ changed: true,
172
+ sha256: null,
173
+ diagnostics: [],
174
+ };
175
+ });
176
+ };
@@ -1,4 +1,6 @@
1
1
  export { inspectPlan } from './utilities.ts';
2
2
  export { createPlan, updatePlan } from './mutations.ts';
3
+ export { writePlanDocument, removeMilestones } from './documents.ts';
4
+ export { withPlanDocumentLocks } from './locks.ts';
3
5
  export type { IDocumentRead, IPlanInspection } from './types.ts';
4
- export type { IPlanCreation, IPlanUpdateResult } from './types.ts';
6
+ export type { IPlanCreation, IPlanUpdateResult, IPlanDocumentWriteResult, IRemoveMilestonesResult, } from './types.ts';
@@ -1,2 +1,4 @@
1
1
  export { inspectPlan } from "./utilities.js";
2
2
  export { createPlan, updatePlan } from "./mutations.js";
3
+ export { writePlanDocument, removeMilestones } from "./documents.js";
4
+ export { withPlanDocumentLocks } from "./locks.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Serialize participating local-filesystem writers using canonical document lock directories.
3
+ * Acquire plan before milestones and release in reverse order. Busy/dead owners fail immediately;
4
+ * recovery is an explicit operator decision. Use preparation and your own persistence inside the
5
+ * callback; nesting a filesystem mutation here would attempt to acquire the same lock again.
6
+ * @param directory Existing selected directory; aliases share its canonical lock namespace.
7
+ * @param documents Nonempty unique selection of plan and/or milestones, in any input order.
8
+ * @param operation One bounded operation, receiving the canonical directory. Never hold a lock across agent turns.
9
+ * @returns The callback's result after every lock is released.
10
+ * @throws
11
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
12
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
13
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
14
+ * - Callback errors propagate after successful lock release.
15
+ */
16
+ export declare const withPlanDocumentLocks: <T>(directory: string, documents: readonly ("plan" | "milestones")[], operation: (directory: string) => T | Promise<T>) => Promise<T>;
@@ -0,0 +1,127 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { getErrorCode } from 'jg-exception';
4
+ import { acquireLock } from 'jg-fs';
5
+ import { z } from 'zod';
6
+ import { planErrors } from "../errors/index.js";
7
+ const DocumentsSchema = z
8
+ .array(z.enum(['plan', 'milestones']))
9
+ .min(1)
10
+ .refine((values) => new Set(values).size === values.length);
11
+ const DirectorySchema = z
12
+ .string()
13
+ .min(1)
14
+ .refine((value) => !value.includes('\0'));
15
+ const BusyReasonSchema = z.object({
16
+ data: z.object({
17
+ reason: z.enum([
18
+ 'held',
19
+ 'owner-gone',
20
+ 'other-host',
21
+ 'ownerless',
22
+ 'recovery-in-progress',
23
+ 'unexpected-entries',
24
+ ]),
25
+ }),
26
+ });
27
+ const reason = (cause) => {
28
+ const busy = BusyReasonSchema.safeParse(cause);
29
+ return busy.success ? busy.data.data.reason : (getErrorCode(cause) ?? 'unknown');
30
+ };
31
+ /**
32
+ * Serialize participating local-filesystem writers using canonical document lock directories.
33
+ * Acquire plan before milestones and release in reverse order. Busy/dead owners fail immediately;
34
+ * recovery is an explicit operator decision. Use preparation and your own persistence inside the
35
+ * callback; nesting a filesystem mutation here would attempt to acquire the same lock again.
36
+ * @param directory Existing selected directory; aliases share its canonical lock namespace.
37
+ * @param documents Nonempty unique selection of plan and/or milestones, in any input order.
38
+ * @param operation One bounded operation, receiving the canonical directory. Never hold a lock across agent turns.
39
+ * @returns The callback's result after every lock is released.
40
+ * @throws
41
+ * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
42
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
43
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
44
+ * - Callback errors propagate after successful lock release.
45
+ */
46
+ export const withPlanDocumentLocks = async (directory, documents, operation) => {
47
+ const selected = DocumentsSchema.safeParse(documents);
48
+ if (!DirectorySchema.safeParse(directory).success ||
49
+ !selected.success ||
50
+ typeof operation !== 'function')
51
+ throw planErrors.create('invalidInput');
52
+ let canonical;
53
+ try {
54
+ canonical = await realpath(resolve(directory));
55
+ }
56
+ catch (cause) {
57
+ throw planErrors.create('lockFailed', {
58
+ cause,
59
+ data: {
60
+ phase: 'canonicalize',
61
+ reason: reason(cause),
62
+ mutationOutcome: 'not-applied',
63
+ operationCompleted: false,
64
+ },
65
+ });
66
+ }
67
+ const held = [];
68
+ let started = false;
69
+ let completed = false;
70
+ let failed = false;
71
+ let failure;
72
+ let result;
73
+ try {
74
+ for (const document of ['plan', 'milestones']) {
75
+ if (!selected.data.includes(document))
76
+ continue;
77
+ try {
78
+ held.push({
79
+ document,
80
+ lock: await acquireLock(join(canonical, `.${document}.md.jg-plans.lock`)),
81
+ });
82
+ }
83
+ catch (cause) {
84
+ throw planErrors.create(getErrorCode(cause) === 'LOCK_BUSY' ? 'lockBusy' : 'lockFailed', {
85
+ cause,
86
+ data: {
87
+ document,
88
+ phase: 'acquire',
89
+ reason: reason(cause),
90
+ mutationOutcome: 'not-applied',
91
+ operationCompleted: false,
92
+ },
93
+ });
94
+ }
95
+ }
96
+ started = true;
97
+ result = await operation(canonical);
98
+ completed = true;
99
+ }
100
+ catch (cause) {
101
+ failed = true;
102
+ failure = cause;
103
+ }
104
+ const releases = [];
105
+ for (const entry of held.reverse()) {
106
+ try {
107
+ await entry.lock.release();
108
+ }
109
+ catch (cause) {
110
+ releases.push({ document: entry.document, cause });
111
+ }
112
+ }
113
+ if (releases.length)
114
+ throw planErrors.create('lockFailed', {
115
+ cause: new AggregateError([...(failed ? [failure] : []), ...releases.map((entry) => entry.cause)], 'The document operation or lock release failed'),
116
+ data: {
117
+ phase: 'release',
118
+ documents: releases.map((entry) => entry.document),
119
+ reason: reason(releases[0].cause),
120
+ operationCompleted: completed,
121
+ mutationOutcome: started ? 'uncertain' : 'not-applied',
122
+ },
123
+ });
124
+ if (failed)
125
+ throw failure;
126
+ return result;
127
+ };
@@ -9,13 +9,15 @@ import type { IPlanCreation, IPlanUpdateResult } from './types.ts';
9
9
  * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
10
10
  * - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
11
11
  * - JG_PLANS_DESTINATION_EXISTS: The plan destination already exists
12
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
13
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
12
14
  * - JG_PLANS_WRITE_FAILED: The plan document could not be written
13
15
  */
14
16
  export declare const createPlan: (options: ICreatePlanOptions) => Promise<IPlanCreation>;
15
17
  /**
16
- * Apply one explicit metadata or marker patch, preserving every other source byte.
17
- * Callers serialize the whole read/edit/write operation. The final byte comparison
18
- * detects observed changes but has a comparison-to-rename race; no retry or lock is used.
18
+ * Apply an explicit narrow patch under the shared selected-document lock.
19
+ * Guard optional observed bytes before preparation; unchanged output is never replaced.
20
+ * Nonparticipating editors retain a final comparison-to-rename race.
19
21
  * @throws
20
22
  * - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
21
23
  * - JG_PLANS_READ_FAILED: The selected document could not be read
@@ -23,5 +25,7 @@ export declare const createPlan: (options: ICreatePlanOptions) => Promise<IPlanC
23
25
  * - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
24
26
  * - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
25
27
  * - JG_PLANS_WRITE_FAILED: The plan document could not be written
28
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
29
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
26
30
  */
27
31
  export declare const updatePlan: (directory: string, patch: IPlanUpdate, options?: IReadOptions) => Promise<IPlanUpdateResult>;