orbitmap 0.3.0 → 0.4.1

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 (174) hide show
  1. package/README.md +546 -212
  2. package/dist/adapters/cloud.d.ts +367 -0
  3. package/dist/adapters/cloud.js +582 -0
  4. package/dist/adapters/cloud.js.map +1 -0
  5. package/dist/adapters/factory.d.ts +15 -0
  6. package/dist/adapters/factory.js +31 -0
  7. package/dist/adapters/factory.js.map +1 -0
  8. package/dist/adapters/local/adapter.d.ts +351 -0
  9. package/dist/adapters/local/adapter.js +346 -0
  10. package/dist/adapters/local/adapter.js.map +1 -0
  11. package/dist/adapters/local/context.d.ts +112 -0
  12. package/dist/adapters/local/context.js +137 -0
  13. package/dist/adapters/local/context.js.map +1 -0
  14. package/dist/adapters/local/entities/documents.d.ts +85 -0
  15. package/dist/adapters/local/entities/documents.js +251 -0
  16. package/dist/adapters/local/entities/documents.js.map +1 -0
  17. package/dist/adapters/local/entities/planning.d.ts +108 -0
  18. package/dist/adapters/local/entities/planning.js +540 -0
  19. package/dist/adapters/local/entities/planning.js.map +1 -0
  20. package/dist/adapters/local/entities/tasks.d.ts +95 -0
  21. package/dist/adapters/local/entities/tasks.js +442 -0
  22. package/dist/adapters/local/entities/tasks.js.map +1 -0
  23. package/dist/adapters/local/entities/work-items.d.ts +110 -0
  24. package/dist/adapters/local/entities/work-items.js +623 -0
  25. package/dist/adapters/local/entities/work-items.js.map +1 -0
  26. package/dist/adapters/local/rules.d.ts +172 -0
  27. package/dist/adapters/local/rules.js +319 -0
  28. package/dist/adapters/local/rules.js.map +1 -0
  29. package/dist/adapters/local/shapes.d.ts +209 -0
  30. package/dist/adapters/local/shapes.js +558 -0
  31. package/dist/adapters/local/shapes.js.map +1 -0
  32. package/dist/adapters/local/store.d.ts +200 -0
  33. package/dist/adapters/local/store.js +665 -0
  34. package/dist/adapters/local/store.js.map +1 -0
  35. package/dist/adapters/local/types.d.ts +226 -0
  36. package/dist/adapters/local/types.js +41 -0
  37. package/dist/adapters/local/types.js.map +1 -0
  38. package/dist/adapters/types.d.ts +332 -0
  39. package/dist/adapters/types.js +2 -0
  40. package/dist/adapters/types.js.map +1 -0
  41. package/dist/agent-instructions.d.ts +25 -9
  42. package/dist/agent-instructions.js +285 -237
  43. package/dist/agent-instructions.js.map +1 -1
  44. package/dist/api-client.d.ts +8 -239
  45. package/dist/api-client.js +8 -403
  46. package/dist/api-client.js.map +1 -1
  47. package/dist/commands/agents.d.ts +1 -1
  48. package/dist/commands/agents.js +10 -10
  49. package/dist/commands/agents.js.map +1 -1
  50. package/dist/commands/areas.d.ts +3 -0
  51. package/dist/commands/areas.js +29 -0
  52. package/dist/commands/areas.js.map +1 -0
  53. package/dist/commands/assign.d.ts +3 -2
  54. package/dist/commands/assign.js +52 -15
  55. package/dist/commands/assign.js.map +1 -1
  56. package/dist/commands/context.d.ts +1 -1
  57. package/dist/commands/context.js +18 -12
  58. package/dist/commands/context.js.map +1 -1
  59. package/dist/commands/create-area.d.ts +7 -0
  60. package/dist/commands/create-area.js +29 -0
  61. package/dist/commands/create-area.js.map +1 -0
  62. package/dist/commands/create.d.ts +3 -1
  63. package/dist/commands/create.js +5 -8
  64. package/dist/commands/create.js.map +1 -1
  65. package/dist/commands/dep.d.ts +2 -2
  66. package/dist/commands/dep.js +3 -3
  67. package/dist/commands/dep.js.map +1 -1
  68. package/dist/commands/doc-import.d.ts +1 -1
  69. package/dist/commands/doc-import.js +2 -2
  70. package/dist/commands/doc-import.js.map +1 -1
  71. package/dist/commands/doc-patch.d.ts +1 -1
  72. package/dist/commands/doc-patch.js +2 -2
  73. package/dist/commands/doc-patch.js.map +1 -1
  74. package/dist/commands/doc-share.d.ts +6 -6
  75. package/dist/commands/doc-share.js +13 -13
  76. package/dist/commands/doc-share.js.map +1 -1
  77. package/dist/commands/doc-update.d.ts +1 -1
  78. package/dist/commands/doc-update.js +2 -2
  79. package/dist/commands/doc-update.js.map +1 -1
  80. package/dist/commands/doc.d.ts +1 -1
  81. package/dist/commands/doc.js +5 -4
  82. package/dist/commands/doc.js.map +1 -1
  83. package/dist/commands/docs-cache.d.ts +2 -2
  84. package/dist/commands/docs-cache.js +3 -3
  85. package/dist/commands/docs-cache.js.map +1 -1
  86. package/dist/commands/docs.d.ts +1 -1
  87. package/dist/commands/docs.js +2 -2
  88. package/dist/commands/docs.js.map +1 -1
  89. package/dist/commands/get.d.ts +1 -1
  90. package/dist/commands/get.js +43 -3
  91. package/dist/commands/get.js.map +1 -1
  92. package/dist/commands/ideas.d.ts +4 -4
  93. package/dist/commands/ideas.js +6 -11
  94. package/dist/commands/ideas.js.map +1 -1
  95. package/dist/commands/init.d.ts +45 -3
  96. package/dist/commands/init.js +452 -104
  97. package/dist/commands/init.js.map +1 -1
  98. package/dist/commands/intent.d.ts +41 -0
  99. package/dist/commands/intent.js +237 -0
  100. package/dist/commands/intent.js.map +1 -0
  101. package/dist/commands/issues.d.ts +6 -5
  102. package/dist/commands/issues.js +8 -12
  103. package/dist/commands/issues.js.map +1 -1
  104. package/dist/commands/log.d.ts +1 -1
  105. package/dist/commands/log.js +2 -2
  106. package/dist/commands/log.js.map +1 -1
  107. package/dist/commands/mission.d.ts +42 -0
  108. package/dist/commands/mission.js +219 -0
  109. package/dist/commands/mission.js.map +1 -0
  110. package/dist/commands/orbit.d.ts +40 -3
  111. package/dist/commands/orbit.js +231 -58
  112. package/dist/commands/orbit.js.map +1 -1
  113. package/dist/commands/overview.d.ts +2 -2
  114. package/dist/commands/overview.js +25 -28
  115. package/dist/commands/overview.js.map +1 -1
  116. package/dist/commands/setup-agent.d.ts +27 -0
  117. package/dist/commands/setup-agent.js +85 -30
  118. package/dist/commands/setup-agent.js.map +1 -1
  119. package/dist/commands/setup-mcp.d.ts +1 -1
  120. package/dist/commands/setup-mcp.js +20 -49
  121. package/dist/commands/setup-mcp.js.map +1 -1
  122. package/dist/commands/start.d.ts +1 -1
  123. package/dist/commands/start.js +2 -2
  124. package/dist/commands/start.js.map +1 -1
  125. package/dist/commands/status.d.ts +2 -1
  126. package/dist/commands/status.js +3 -3
  127. package/dist/commands/status.js.map +1 -1
  128. package/dist/commands/subtask.d.ts +1 -1
  129. package/dist/commands/subtask.js +2 -2
  130. package/dist/commands/subtask.js.map +1 -1
  131. package/dist/commands/task-edit-content.d.ts +1 -1
  132. package/dist/commands/task-edit-content.js +2 -2
  133. package/dist/commands/task-edit-content.js.map +1 -1
  134. package/dist/commands/task.d.ts +10 -3
  135. package/dist/commands/task.js +19 -21
  136. package/dist/commands/task.js.map +1 -1
  137. package/dist/commands/tasks.d.ts +1 -1
  138. package/dist/commands/tasks.js +2 -2
  139. package/dist/commands/tasks.js.map +1 -1
  140. package/dist/commands/vibes.d.ts +4 -4
  141. package/dist/commands/vibes.js +5 -5
  142. package/dist/commands/vibes.js.map +1 -1
  143. package/dist/config.d.ts +69 -9
  144. package/dist/config.js +206 -23
  145. package/dist/config.js.map +1 -1
  146. package/dist/doc-cache.d.ts +23 -3
  147. package/dist/doc-cache.js +72 -12
  148. package/dist/doc-cache.js.map +1 -1
  149. package/dist/errors.d.ts +19 -2
  150. package/dist/errors.js +35 -0
  151. package/dist/errors.js.map +1 -1
  152. package/dist/id-resolve.d.ts +61 -0
  153. package/dist/id-resolve.js +87 -0
  154. package/dist/id-resolve.js.map +1 -0
  155. package/dist/index.js +477 -104
  156. package/dist/index.js.map +1 -1
  157. package/dist/mcp-config.d.ts +36 -0
  158. package/dist/mcp-config.js +51 -0
  159. package/dist/mcp-config.js.map +1 -0
  160. package/dist/oauth.d.ts +8 -0
  161. package/dist/oauth.js +54 -15
  162. package/dist/oauth.js.map +1 -1
  163. package/dist/output.js +7 -2
  164. package/dist/output.js.map +1 -1
  165. package/dist/paths.d.ts +97 -0
  166. package/dist/paths.js +178 -0
  167. package/dist/paths.js.map +1 -0
  168. package/dist/project-config.d.ts +75 -0
  169. package/dist/project-config.js +55 -0
  170. package/dist/project-config.js.map +1 -0
  171. package/dist/workspace-resolve.d.ts +158 -0
  172. package/dist/workspace-resolve.js +284 -0
  173. package/dist/workspace-resolve.js.map +1 -0
  174. package/package.json +53 -50
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Local file store (spec §4).
3
+ *
4
+ * All file I/O for local mode lives here: frontmatter+body parsing, atomic writes,
5
+ * advisory locking, the workspace scaffold, entity-number counters and lookups.
6
+ * It knows nothing about the Agent API envelopes (that is `shapes.ts`) and nothing
7
+ * about domain validation (that is `rules.ts`).
8
+ *
9
+ * Everything is async and every path is built with `node:path`, so the store behaves
10
+ * identically on Windows and POSIX.
11
+ */
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from 'node:fs/promises';
15
+ import { basename, dirname, extname, join, resolve } from 'node:path';
16
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
17
+ import { OrbitMapAPIError } from '../../errors.js';
18
+ import { AREA_SCOPED_KINDS, ENTITY_PREFIXES, KIND_BY_PREFIX, LOCAL_SCHEMA_VERSION, WORKSPACE_SCOPED_KINDS, } from './types.js';
19
+ // ── Error codes ─────────────────────────────────────────────────────────────
20
+ /** Unexpected/corrupt state on disk (unreadable file, bad frontmatter, failed write). */
21
+ export const LOCAL_STORE_ERROR = 'LOCAL_STORE_ERROR';
22
+ /** The advisory lock could not be acquired within the retry budget. */
23
+ export const LOCK_TIMEOUT = 'LOCK_TIMEOUT';
24
+ /** A referenced entity/area/document does not exist on disk. */
25
+ export const NOT_FOUND = 'NOT_FOUND';
26
+ // ── Layout constants (spec §4.1) ────────────────────────────────────────────
27
+ export const WORKSPACE_FILE_NAME = 'workspace.yml';
28
+ export const AREA_FILE_NAME = 'area.yml';
29
+ export const LOCK_FILE_NAME = '.lock';
30
+ export const AREAS_DIR = 'areas';
31
+ export const DOCUMENTS_DIR = 'documents';
32
+ /** Workspace-level directories created by the scaffold. */
33
+ export const WORKSPACE_DIRS = [
34
+ 'missions',
35
+ 'intents',
36
+ 'ideas',
37
+ 'documents',
38
+ 'issues',
39
+ ];
40
+ /** Per-area directories created by the scaffold. */
41
+ export const AREA_DIRS = ['tasks', 'issues', 'vibes'];
42
+ /** Directory name (relative to the workspace root) for a workspace-scoped kind. */
43
+ const WORKSPACE_DIR_BY_KIND = {
44
+ task: 'tasks',
45
+ issue: 'issues',
46
+ idea: 'ideas',
47
+ vibe: 'vibes',
48
+ intent: 'intents',
49
+ mission: 'missions',
50
+ };
51
+ // ── Frontmatter parsing / serialisation ─────────────────────────────────────
52
+ const OPEN_DELIMITER = /^---[ \t]*\r?\n/;
53
+ const CLOSE_DELIMITER = /^---[ \t]*(?:\r?\n|$)/m;
54
+ /**
55
+ * Split a Markdown file into its YAML frontmatter object and its body.
56
+ *
57
+ * The delimiter is `---` on line 1. The *whole* YAML mapping is returned, so unknown
58
+ * keys survive a read → modify → write round trip.
59
+ */
60
+ export function parseFrontmatter(raw, source) {
61
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
62
+ const opening = OPEN_DELIMITER.exec(text);
63
+ if (!opening) {
64
+ throw storeError(`Missing YAML frontmatter (expected \`---\` on the first line)${where(source)}.`);
65
+ }
66
+ const rest = text.slice(opening[0].length);
67
+ const closing = CLOSE_DELIMITER.exec(rest);
68
+ if (!closing) {
69
+ throw storeError(`Unterminated YAML frontmatter${where(source)}.`);
70
+ }
71
+ const yamlText = rest.slice(0, closing.index);
72
+ const body = rest.slice(closing.index + closing[0].length);
73
+ let parsed;
74
+ try {
75
+ parsed = yamlText.trim().length > 0 ? parseYaml(yamlText) : {};
76
+ }
77
+ catch (err) {
78
+ throw storeError(`Invalid YAML frontmatter${where(source)}: ${err.message}`);
79
+ }
80
+ if (parsed === null || parsed === undefined)
81
+ parsed = {};
82
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
83
+ throw storeError(`YAML frontmatter must be a mapping${where(source)}.`);
84
+ }
85
+ return { frontmatter: parsed, body: normaliseBody(body) };
86
+ }
87
+ /** Render a frontmatter object + body back into a Markdown file. */
88
+ export function serialiseFrontmatter(frontmatter, body = '') {
89
+ let yamlText;
90
+ try {
91
+ yamlText = stringifyYaml(frontmatter, { lineWidth: 0 });
92
+ }
93
+ catch (err) {
94
+ throw storeError(`Could not serialise frontmatter: ${err.message}`);
95
+ }
96
+ if (!yamlText.endsWith('\n'))
97
+ yamlText += '\n';
98
+ const normalised = normaliseBody(body);
99
+ return normalised.length > 0
100
+ ? `---\n${yamlText}---\n\n${normalised}\n`
101
+ : `---\n${yamlText}---\n`;
102
+ }
103
+ /** Strip leading blank lines and trailing whitespace so round trips are stable. */
104
+ function normaliseBody(body) {
105
+ return body.replace(/^(?:[ \t]*\r?\n)+/, '').replace(/\s+$/, '');
106
+ }
107
+ // ── Entity numbers (spec §4.4) ──────────────────────────────────────────────
108
+ /** `prefix + '-' + counter.toString(36).padStart(6, '0')`, e.g. `TS-000001`. */
109
+ export function formatEntityNumber(kind, counter) {
110
+ return `${ENTITY_PREFIXES[kind]}-${counter.toString(36).padStart(6, '0')}`;
111
+ }
112
+ /** The entity kind an entity number belongs to, or `undefined` if not a number. */
113
+ export function kindOfEntityNumber(ref) {
114
+ const match = /^([A-Za-z]{2})-([0-9a-zA-Z]+)$/.exec(ref.trim());
115
+ if (!match)
116
+ return undefined;
117
+ return KIND_BY_PREFIX[match[1].toUpperCase()];
118
+ }
119
+ /** True when `ref` looks like `TS-…`/`IS-…`/… rather than a uuid or slug. */
120
+ export function isEntityNumber(ref) {
121
+ return kindOfEntityNumber(ref) !== undefined;
122
+ }
123
+ /** In-process serialisation of lock acquisition, keyed by absolute lock path. */
124
+ const inProcessLocks = new Map();
125
+ /** Lock paths held by the current async execution context (makes `withLock` reentrant). */
126
+ const heldLocks = new AsyncLocalStorage();
127
+ export class LocalStore {
128
+ /** Absolute path of the workspace root directory. */
129
+ root;
130
+ lockRetries;
131
+ lockRetryDelayMs;
132
+ constructor(workspacePath, options = {}) {
133
+ this.root = resolve(workspacePath);
134
+ this.lockRetries = options.lockRetries ?? 10;
135
+ this.lockRetryDelayMs = options.lockRetryDelayMs ?? 100;
136
+ }
137
+ // ── Paths ────────────────────────────────────────────────────────────────
138
+ get workspaceFile() {
139
+ return join(this.root, WORKSPACE_FILE_NAME);
140
+ }
141
+ get lockFile() {
142
+ return join(this.root, LOCK_FILE_NAME);
143
+ }
144
+ /** `<root>/areas/<slug>`. */
145
+ areaDir(slug) {
146
+ return join(this.root, AREAS_DIR, slug);
147
+ }
148
+ /** `<root>/areas/<slug>/area.yml`. */
149
+ areaFile(slug) {
150
+ return join(this.areaDir(slug), AREA_FILE_NAME);
151
+ }
152
+ /**
153
+ * Directory holding entities of `kind`. Area-scoped kinds require `area`;
154
+ * issues live in the area dir when `area` is given and in `<root>/issues` otherwise.
155
+ */
156
+ entityDir(kind, area) {
157
+ const dirName = WORKSPACE_DIR_BY_KIND[kind];
158
+ if (area) {
159
+ if (kind === 'task' || kind === 'vibe' || kind === 'issue') {
160
+ return join(this.areaDir(area), dirName);
161
+ }
162
+ // Workspace-scoped kinds ignore the area argument.
163
+ return join(this.root, dirName);
164
+ }
165
+ if (AREA_SCOPED_KINDS.includes(kind)) {
166
+ throw storeError(`${kind} entities are area-scoped — an area slug is required.`);
167
+ }
168
+ return join(this.root, dirName);
169
+ }
170
+ /** Absolute path of an entity file. */
171
+ entityPath(kind, entityNumber, area) {
172
+ return join(this.entityDir(kind, area), `${entityNumber}.md`);
173
+ }
174
+ /** `<root>/documents/<slug>.md`. */
175
+ documentPath(slug) {
176
+ return join(this.root, DOCUMENTS_DIR, `${slug}.md`);
177
+ }
178
+ // ── Scaffold ─────────────────────────────────────────────────────────────
179
+ /** True when `<root>/workspace.yml` exists. */
180
+ async isWorkspace() {
181
+ return pathExists(this.workspaceFile);
182
+ }
183
+ /**
184
+ * Create the full directory layout of §4.1 plus `workspace.yml`, and optionally the
185
+ * first area. Idempotent: re-scaffolding an existing workspace only fills in gaps and
186
+ * leaves `workspace.yml` (and its counters) untouched.
187
+ */
188
+ async scaffold(options) {
189
+ await mkdir(this.root, { recursive: true });
190
+ for (const dir of WORKSPACE_DIRS) {
191
+ await mkdir(join(this.root, dir), { recursive: true });
192
+ }
193
+ await mkdir(join(this.root, AREAS_DIR), { recursive: true });
194
+ // The first area becomes the workspace's own default (spec §3.3) so that an
195
+ // in-project workspace outranks the machine-wide `area` of the global config.
196
+ const defaultArea = options.area
197
+ ? (options.area.slug ?? slugify(options.area.name))
198
+ : undefined;
199
+ let workspace;
200
+ if (await this.isWorkspace()) {
201
+ workspace = await this.readWorkspace();
202
+ // Fill in — never clobber: a workspace scaffolded by an older CLI gains its
203
+ // `default_area` on the next `init`, one that already declares an area keeps it.
204
+ if (defaultArea && !workspace.default_area) {
205
+ workspace.default_area = defaultArea;
206
+ await this.writeWorkspace(workspace);
207
+ }
208
+ }
209
+ else {
210
+ workspace = {
211
+ schema_version: LOCAL_SCHEMA_VERSION,
212
+ workspace_id: options.workspaceId ?? randomUUID(),
213
+ name: options.name,
214
+ slug: options.slug ?? slugify(options.name),
215
+ description: options.description ?? '',
216
+ settings: {},
217
+ created_at: nowIso(),
218
+ counters: emptyCounters(),
219
+ ...(defaultArea ? { default_area: defaultArea } : {}),
220
+ };
221
+ await this.writeWorkspace(workspace);
222
+ }
223
+ if (options.area)
224
+ await this.scaffoldArea(options.area);
225
+ return workspace;
226
+ }
227
+ /**
228
+ * Create `areas/<slug>/` with its `tasks/`, `issues/`, `vibes/` subdirectories and
229
+ * `area.yml`. Returns the existing `area.yml` untouched when the area already exists.
230
+ */
231
+ async scaffoldArea(input) {
232
+ const slug = input.slug ?? slugify(input.name);
233
+ const dir = this.areaDir(slug);
234
+ await mkdir(dir, { recursive: true });
235
+ for (const sub of AREA_DIRS) {
236
+ await mkdir(join(dir, sub), { recursive: true });
237
+ }
238
+ const existing = await this.tryReadArea(slug);
239
+ if (existing)
240
+ return existing;
241
+ const area = {
242
+ area_id: input.areaId ?? randomUUID(),
243
+ name: input.name,
244
+ slug,
245
+ description: input.description ?? '',
246
+ git_url: input.gitUrl ?? null,
247
+ default_branch: input.defaultBranch ?? 'main',
248
+ agent_instructions_template: input.agentInstructionsTemplate ?? null,
249
+ settings: {},
250
+ created_at: nowIso(),
251
+ };
252
+ await this.writeArea(area);
253
+ return area;
254
+ }
255
+ // ── workspace.yml / area.yml ─────────────────────────────────────────────
256
+ async readWorkspace() {
257
+ const raw = await readTextFile(this.workspaceFile);
258
+ if (raw === undefined) {
259
+ throw OrbitMapAPIError.workspaceNotFound(`No \`${WORKSPACE_FILE_NAME}\` found in ${this.root}. Run \`orbitmap init\` to create a workspace.`);
260
+ }
261
+ const parsed = parseYamlObject(raw, this.workspaceFile);
262
+ if (!parsed.counters)
263
+ parsed.counters = emptyCounters();
264
+ return parsed;
265
+ }
266
+ async writeWorkspace(workspace) {
267
+ await writeAtomic(this.workspaceFile, toYamlDocument(workspace));
268
+ }
269
+ async readArea(slug) {
270
+ const area = await this.tryReadArea(slug);
271
+ if (!area) {
272
+ throw new OrbitMapAPIError(`Area \`${slug}\` was not found in this workspace.`, NOT_FOUND, 404);
273
+ }
274
+ return area;
275
+ }
276
+ async tryReadArea(slug) {
277
+ const raw = await readTextFile(this.areaFile(slug));
278
+ if (raw === undefined)
279
+ return undefined;
280
+ return parseYamlObject(raw, this.areaFile(slug));
281
+ }
282
+ async writeArea(area) {
283
+ await writeAtomic(this.areaFile(area.slug), toYamlDocument(area));
284
+ }
285
+ /** Every area in the workspace, ordered by slug. */
286
+ async listAreas() {
287
+ const slugs = await listDirNames(join(this.root, AREAS_DIR));
288
+ const areas = [];
289
+ for (const slug of slugs.sort()) {
290
+ const area = await this.tryReadArea(slug);
291
+ if (area)
292
+ areas.push(area);
293
+ }
294
+ return areas;
295
+ }
296
+ /** Look an area up by slug; `undefined` when it does not exist. */
297
+ async findAreaBySlug(slug) {
298
+ return this.tryReadArea(slug);
299
+ }
300
+ // ── Locking (spec §4.12) ─────────────────────────────────────────────────
301
+ /**
302
+ * Run `fn` while holding the workspace's advisory `.lock` file.
303
+ *
304
+ * Guards multi-file operations (counter bump + entity write) against concurrent CLI
305
+ * invocations. Reentrant within one async context, serialised within one process, and
306
+ * retried `lockRetries` × `lockRetryDelayMs` against other processes before failing.
307
+ * The lock is always released in a `finally`.
308
+ */
309
+ async withLock(fn) {
310
+ const key = this.lockFile;
311
+ const held = heldLocks.getStore();
312
+ if (held?.has(key))
313
+ return fn();
314
+ const previous = inProcessLocks.get(key) ?? Promise.resolve();
315
+ let release;
316
+ const mine = new Promise((res) => {
317
+ release = res;
318
+ });
319
+ const chained = previous.then(() => mine);
320
+ inProcessLocks.set(key, chained);
321
+ await previous.catch(() => undefined);
322
+ try {
323
+ await this.acquireLockFile();
324
+ const nested = new Set(held ?? []);
325
+ nested.add(key);
326
+ try {
327
+ return await heldLocks.run(nested, fn);
328
+ }
329
+ finally {
330
+ await this.releaseLockFile();
331
+ }
332
+ }
333
+ finally {
334
+ release();
335
+ if (inProcessLocks.get(key) === chained)
336
+ inProcessLocks.delete(key);
337
+ }
338
+ }
339
+ async acquireLockFile() {
340
+ await mkdir(this.root, { recursive: true });
341
+ for (let attempt = 0; attempt <= this.lockRetries; attempt += 1) {
342
+ try {
343
+ const handle = await open(this.lockFile, 'wx');
344
+ try {
345
+ await handle.writeFile(`${process.pid}\n${nowIso()}\n`, 'utf8');
346
+ }
347
+ finally {
348
+ await handle.close();
349
+ }
350
+ return;
351
+ }
352
+ catch (err) {
353
+ if (!isErrnoCode(err, 'EEXIST')) {
354
+ throw storeError(`Could not create the workspace lock at ${this.lockFile}: ${err.message}`);
355
+ }
356
+ if (attempt < this.lockRetries)
357
+ await delay(this.lockRetryDelayMs);
358
+ }
359
+ }
360
+ throw new OrbitMapAPIError(`The OrbitMap workspace is locked by another process (${this.lockFile}). ` +
361
+ `Waited ${this.lockRetries * this.lockRetryDelayMs}ms. ` +
362
+ 'If no other `orbitmap` command is running, delete that file and retry.', LOCK_TIMEOUT, 409);
363
+ }
364
+ async releaseLockFile() {
365
+ await rm(this.lockFile, { force: true });
366
+ }
367
+ // ── Counters (spec §4.4) ─────────────────────────────────────────────────
368
+ /**
369
+ * Bump the counter for `kind` in `workspace.yml` and return the new entity number.
370
+ * Runs under the advisory lock, so concurrent creates never collide.
371
+ */
372
+ async nextEntityNumber(kind) {
373
+ return this.withLock(async () => {
374
+ const workspace = await this.readWorkspace();
375
+ const counters = workspace.counters ?? emptyCounters();
376
+ const next = (Number(counters[kind]) || 0) + 1;
377
+ counters[kind] = next;
378
+ workspace.counters = counters;
379
+ await this.writeWorkspace(workspace);
380
+ return formatEntityNumber(kind, next);
381
+ });
382
+ }
383
+ // ── Entity read / write ──────────────────────────────────────────────────
384
+ /**
385
+ * Allocate the next entity number and write the file in a single locked operation.
386
+ * `build` receives the freshly allocated number and returns the record to persist.
387
+ */
388
+ async createEntity(kind, build, options = {}) {
389
+ return this.withLock(async () => {
390
+ const entityNumber = await this.nextEntityNumber(kind);
391
+ const { frontmatter, body } = build(entityNumber);
392
+ const path = this.entityPath(kind, entityNumber, options.area);
393
+ await writeAtomic(path, serialiseFrontmatter(frontmatter, body ?? ''));
394
+ return {
395
+ kind,
396
+ area: options.area ?? null,
397
+ path,
398
+ frontmatter,
399
+ body: normaliseBody(body ?? ''),
400
+ };
401
+ });
402
+ }
403
+ /** Read an entity file by kind + number. Throws `NOT_FOUND` when missing. */
404
+ async readEntity(kind, entityNumber, options = {}) {
405
+ const found = await this.tryReadEntity(kind, entityNumber, options);
406
+ if (!found) {
407
+ throw new OrbitMapAPIError(`${entityNumber} was not found in this workspace.`, NOT_FOUND, 404);
408
+ }
409
+ return found;
410
+ }
411
+ /** Read an entity file by kind + number, or `undefined` when it does not exist. */
412
+ async tryReadEntity(kind, entityNumber, options = {}) {
413
+ if (options.area !== undefined && options.area !== null) {
414
+ return this.readEntityAt(this.entityPath(kind, entityNumber, options.area), kind, options.area);
415
+ }
416
+ for (const candidate of await this.candidatePaths(kind, entityNumber)) {
417
+ const file = await this.readEntityAt(candidate.path, kind, candidate.area);
418
+ if (file)
419
+ return file;
420
+ }
421
+ return undefined;
422
+ }
423
+ /** Persist an entity file that was previously read (or built) by the store. */
424
+ async writeEntity(file) {
425
+ await writeAtomic(file.path, serialiseFrontmatter(file.frontmatter, file.body));
426
+ }
427
+ /** Write a raw frontmatter+body pair to an explicit path (atomic). */
428
+ async writeFileRecord(path, frontmatter, body = '') {
429
+ await writeAtomic(path, serialiseFrontmatter(frontmatter, body));
430
+ }
431
+ // ── Lookups ──────────────────────────────────────────────────────────────
432
+ /**
433
+ * Locate any entity by its entity number (`TS-000001`) or by its uuid.
434
+ *
435
+ * Entity numbers resolve directly from the prefix + directory layout; uuids require a
436
+ * scan of the workspace (small by construction). Returns `undefined` when not found.
437
+ */
438
+ async findEntity(ref) {
439
+ const trimmed = ref.trim();
440
+ const kind = kindOfEntityNumber(trimmed);
441
+ if (kind) {
442
+ const upper = `${trimmed.slice(0, 2).toUpperCase()}${trimmed.slice(2)}`;
443
+ return this.tryReadEntity(kind, upper);
444
+ }
445
+ for (const candidate of Object.keys(ENTITY_PREFIXES)) {
446
+ const matches = await this.listEntities(candidate);
447
+ const hit = matches.find((file) => file.frontmatter.id === trimmed);
448
+ if (hit)
449
+ return hit;
450
+ }
451
+ return undefined;
452
+ }
453
+ /** Like {@link findEntity} but throws `NOT_FOUND` instead of returning `undefined`. */
454
+ async requireEntity(ref) {
455
+ const found = await this.findEntity(ref);
456
+ if (!found) {
457
+ throw new OrbitMapAPIError(`\`${ref}\` was not found in this workspace.`, NOT_FOUND, 404);
458
+ }
459
+ return found;
460
+ }
461
+ /**
462
+ * All entities of one kind.
463
+ *
464
+ * - Area-scoped kinds (`task`, `vibe`): `area` restricts to that area; omitting it
465
+ * walks every area.
466
+ * - `issue`: `area` restricts to that area; omitting it returns workspace-level issues
467
+ * *and* every area's issues.
468
+ * - Workspace-scoped kinds: `area` is ignored.
469
+ *
470
+ * Results are sorted by entity number for stable output.
471
+ */
472
+ async listEntities(kind, options = {}) {
473
+ const scopes = await this.scopesFor(kind, options.area);
474
+ const files = [];
475
+ for (const scope of scopes) {
476
+ const dir = this.entityDir(kind, scope);
477
+ for (const name of await listMarkdownFiles(dir)) {
478
+ const file = await this.readEntityAt(join(dir, name), kind, scope);
479
+ if (file)
480
+ files.push(file);
481
+ }
482
+ }
483
+ files.sort((a, b) => basename(a.path).localeCompare(basename(b.path)));
484
+ return files;
485
+ }
486
+ // ── Documents ────────────────────────────────────────────────────────────
487
+ /** Read `documents/<slug>.md`, or `undefined` when it does not exist. */
488
+ async findDocumentBySlug(slug) {
489
+ const path = this.documentPath(slug);
490
+ const raw = await readTextFile(path);
491
+ if (raw === undefined)
492
+ return undefined;
493
+ const { frontmatter, body } = parseFrontmatter(raw, path);
494
+ return { path, frontmatter, body };
495
+ }
496
+ /** Like {@link findDocumentBySlug} but throws `NOT_FOUND` when missing. */
497
+ async readDocument(slug) {
498
+ const doc = await this.findDocumentBySlug(slug);
499
+ if (!doc) {
500
+ throw new OrbitMapAPIError(`Document \`${slug}\` was not found in this workspace.`, NOT_FOUND, 404);
501
+ }
502
+ return doc;
503
+ }
504
+ /** Every document in `documents/`, ordered by slug. */
505
+ async listDocuments() {
506
+ const dir = join(this.root, DOCUMENTS_DIR);
507
+ const docs = [];
508
+ for (const name of await listMarkdownFiles(dir)) {
509
+ const path = join(dir, name);
510
+ const raw = await readTextFile(path);
511
+ if (raw === undefined)
512
+ continue;
513
+ const { frontmatter, body } = parseFrontmatter(raw, path);
514
+ docs.push({ path, frontmatter, body });
515
+ }
516
+ docs.sort((a, b) => basename(a.path).localeCompare(basename(b.path)));
517
+ return docs;
518
+ }
519
+ /** Write `documents/<slug>.md` atomically. */
520
+ async writeDocument(frontmatter, body) {
521
+ const path = this.documentPath(frontmatter.slug);
522
+ await mkdir(dirname(path), { recursive: true });
523
+ await writeAtomic(path, serialiseFrontmatter(frontmatter, body));
524
+ return { path, frontmatter, body: normaliseBody(body) };
525
+ }
526
+ // ── Internals ────────────────────────────────────────────────────────────
527
+ /** Area slugs (or `null` for workspace scope) to search for `kind`. */
528
+ async scopesFor(kind, area) {
529
+ if (WORKSPACE_SCOPED_KINDS.includes(kind))
530
+ return [null];
531
+ if (area)
532
+ return [area];
533
+ const areaSlugs = (await listDirNames(join(this.root, AREAS_DIR))).sort();
534
+ return kind === 'issue' ? [null, ...areaSlugs] : areaSlugs;
535
+ }
536
+ /** Every path an entity number could occupy, in search order. */
537
+ async candidatePaths(kind, entityNumber) {
538
+ const scopes = await this.scopesFor(kind, null);
539
+ return scopes.map((area) => ({
540
+ path: this.entityPath(kind, entityNumber, area),
541
+ area,
542
+ }));
543
+ }
544
+ async readEntityAt(path, kind, area) {
545
+ const raw = await readTextFile(path);
546
+ if (raw === undefined)
547
+ return undefined;
548
+ const { frontmatter, body } = parseFrontmatter(raw, path);
549
+ return { kind, area, path, frontmatter, body };
550
+ }
551
+ }
552
+ // ── Free functions ──────────────────────────────────────────────────────────
553
+ /**
554
+ * Atomic write: serialise to `<file>.tmp` in the same directory, then rename over the
555
+ * target. The temp file is removed on any failure, so no `.tmp` litter is left behind.
556
+ */
557
+ export async function writeAtomic(path, content) {
558
+ const target = resolve(path);
559
+ await mkdir(dirname(target), { recursive: true });
560
+ const tmp = `${target}.tmp`;
561
+ try {
562
+ await writeFile(tmp, content, 'utf8');
563
+ await rename(tmp, target);
564
+ }
565
+ catch (err) {
566
+ await rm(tmp, { force: true }).catch(() => undefined);
567
+ throw storeError(`Could not write ${target}: ${err.message}`);
568
+ }
569
+ }
570
+ /** Fresh counters, all at zero. */
571
+ export function emptyCounters() {
572
+ return { task: 0, issue: 0, idea: 0, vibe: 0, intent: 0, mission: 0 };
573
+ }
574
+ /** ISO-8601 UTC, second precision — the timestamp format used across the format. */
575
+ export function nowIso() {
576
+ return `${new Date().toISOString().slice(0, 19)}Z`;
577
+ }
578
+ /** Lowercase, dash-separated slug — used for workspace/area defaults. */
579
+ export function slugify(value) {
580
+ return (value
581
+ .normalize('NFKD')
582
+ .replace(/[̀-ͯ]/g, '')
583
+ .toLowerCase()
584
+ .replace(/[^a-z0-9]+/g, '-')
585
+ .replace(/^-+|-+$/g, '')
586
+ .slice(0, 60) || 'workspace');
587
+ }
588
+ // ── Small helpers ───────────────────────────────────────────────────────────
589
+ function storeError(message) {
590
+ return new OrbitMapAPIError(message, LOCAL_STORE_ERROR, 500);
591
+ }
592
+ function where(source) {
593
+ return source ? ` in ${source}` : '';
594
+ }
595
+ function parseYamlObject(raw, source) {
596
+ let parsed;
597
+ try {
598
+ parsed = parseYaml(raw);
599
+ }
600
+ catch (err) {
601
+ throw storeError(`Invalid YAML in ${source}: ${err.message}`);
602
+ }
603
+ if (parsed === null || parsed === undefined)
604
+ return {};
605
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
606
+ throw storeError(`Expected a YAML mapping in ${source}.`);
607
+ }
608
+ return parsed;
609
+ }
610
+ function toYamlDocument(value) {
611
+ let text = stringifyYaml(value, { lineWidth: 0 });
612
+ if (!text.endsWith('\n'))
613
+ text += '\n';
614
+ return text;
615
+ }
616
+ async function readTextFile(path) {
617
+ try {
618
+ return await readFile(path, 'utf8');
619
+ }
620
+ catch (err) {
621
+ if (isErrnoCode(err, 'ENOENT') || isErrnoCode(err, 'EISDIR'))
622
+ return undefined;
623
+ throw storeError(`Could not read ${path}: ${err.message}`);
624
+ }
625
+ }
626
+ async function pathExists(path) {
627
+ try {
628
+ await stat(path);
629
+ return true;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ }
635
+ async function listMarkdownFiles(dir) {
636
+ const entries = await safeReaddir(dir);
637
+ return entries
638
+ .filter((entry) => entry.isFile() && extname(entry.name).toLowerCase() === '.md')
639
+ .map((entry) => entry.name);
640
+ }
641
+ async function listDirNames(dir) {
642
+ const entries = await safeReaddir(dir);
643
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
644
+ }
645
+ async function safeReaddir(dir) {
646
+ try {
647
+ return await readdir(dir, { withFileTypes: true });
648
+ }
649
+ catch (err) {
650
+ if (isErrnoCode(err, 'ENOENT') || isErrnoCode(err, 'ENOTDIR'))
651
+ return [];
652
+ throw storeError(`Could not list ${dir}: ${err.message}`);
653
+ }
654
+ }
655
+ function isErrnoCode(err, code) {
656
+ return (typeof err === 'object' &&
657
+ err !== null &&
658
+ err.code === code);
659
+ }
660
+ function delay(ms) {
661
+ return new Promise((res) => {
662
+ setTimeout(res, ms);
663
+ });
664
+ }
665
+ //# sourceMappingURL=store.js.map