@davidorex/pi-context 0.26.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 (126) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +195 -0
  3. package/dist/block-api.d.ts +328 -0
  4. package/dist/block-api.d.ts.map +1 -0
  5. package/dist/block-api.js +1167 -0
  6. package/dist/block-api.js.map +1 -0
  7. package/dist/block-validation.d.ts +33 -0
  8. package/dist/block-validation.d.ts.map +1 -0
  9. package/dist/block-validation.js +213 -0
  10. package/dist/block-validation.js.map +1 -0
  11. package/dist/context-dir.d.ts +93 -0
  12. package/dist/context-dir.d.ts.map +1 -0
  13. package/dist/context-dir.js +224 -0
  14. package/dist/context-dir.js.map +1 -0
  15. package/dist/context-sdk.d.ts +428 -0
  16. package/dist/context-sdk.d.ts.map +1 -0
  17. package/dist/context-sdk.js +1327 -0
  18. package/dist/context-sdk.js.map +1 -0
  19. package/dist/context.d.ts +497 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +1060 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/dispatch-context.d.ts +112 -0
  24. package/dist/dispatch-context.d.ts.map +1 -0
  25. package/dist/dispatch-context.js +119 -0
  26. package/dist/dispatch-context.js.map +1 -0
  27. package/dist/execution-context.d.ts +138 -0
  28. package/dist/execution-context.d.ts.map +1 -0
  29. package/dist/execution-context.js +151 -0
  30. package/dist/execution-context.js.map +1 -0
  31. package/dist/index.d.ts +34 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1655 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lens-validator.d.ts +50 -0
  36. package/dist/lens-validator.d.ts.map +1 -0
  37. package/dist/lens-validator.js +46 -0
  38. package/dist/lens-validator.js.map +1 -0
  39. package/dist/lens-view.d.ts +75 -0
  40. package/dist/lens-view.d.ts.map +1 -0
  41. package/dist/lens-view.js +253 -0
  42. package/dist/lens-view.js.map +1 -0
  43. package/dist/rename-canonical-id.d.ts +64 -0
  44. package/dist/rename-canonical-id.d.ts.map +1 -0
  45. package/dist/rename-canonical-id.js +378 -0
  46. package/dist/rename-canonical-id.js.map +1 -0
  47. package/dist/roadmap-plan.d.ts +186 -0
  48. package/dist/roadmap-plan.d.ts.map +1 -0
  49. package/dist/roadmap-plan.js +534 -0
  50. package/dist/roadmap-plan.js.map +1 -0
  51. package/dist/samples-catalog.d.ts +48 -0
  52. package/dist/samples-catalog.d.ts.map +1 -0
  53. package/dist/samples-catalog.js +128 -0
  54. package/dist/samples-catalog.js.map +1 -0
  55. package/dist/schema-migrations.d.ts +82 -0
  56. package/dist/schema-migrations.d.ts.map +1 -0
  57. package/dist/schema-migrations.js +106 -0
  58. package/dist/schema-migrations.js.map +1 -0
  59. package/dist/schema-validator.d.ts +76 -0
  60. package/dist/schema-validator.d.ts.map +1 -0
  61. package/dist/schema-validator.js +219 -0
  62. package/dist/schema-validator.js.map +1 -0
  63. package/dist/schema-write.d.ts +122 -0
  64. package/dist/schema-write.d.ts.map +1 -0
  65. package/dist/schema-write.js +210 -0
  66. package/dist/schema-write.js.map +1 -0
  67. package/dist/status-vocab.d.ts +60 -0
  68. package/dist/status-vocab.d.ts.map +1 -0
  69. package/dist/status-vocab.js +109 -0
  70. package/dist/status-vocab.js.map +1 -0
  71. package/dist/test-helpers.d.ts +12 -0
  72. package/dist/test-helpers.d.ts.map +1 -0
  73. package/dist/test-helpers.js +28 -0
  74. package/dist/test-helpers.js.map +1 -0
  75. package/dist/topo.d.ts +19 -0
  76. package/dist/topo.d.ts.map +1 -0
  77. package/dist/topo.js +99 -0
  78. package/dist/topo.js.map +1 -0
  79. package/dist/update-check.d.ts +6 -0
  80. package/dist/update-check.d.ts.map +1 -0
  81. package/dist/update-check.js +82 -0
  82. package/dist/update-check.js.map +1 -0
  83. package/package.json +115 -0
  84. package/samples/blocks/context-contracts.json +3 -0
  85. package/samples/blocks/conventions.json +3 -0
  86. package/samples/blocks/decisions.json +3 -0
  87. package/samples/blocks/features.json +3 -0
  88. package/samples/blocks/framework-gaps.json +3 -0
  89. package/samples/blocks/issues.json +3 -0
  90. package/samples/blocks/layer-plans.json +3 -0
  91. package/samples/blocks/phase.json +3 -0
  92. package/samples/blocks/rationale.json +3 -0
  93. package/samples/blocks/requirements.json +3 -0
  94. package/samples/blocks/research.json +3 -0
  95. package/samples/blocks/spec-reviews.json +3 -0
  96. package/samples/blocks/story.json +3 -0
  97. package/samples/blocks/tasks.json +3 -0
  98. package/samples/blocks/verification.json +3 -0
  99. package/samples/conception.json +467 -0
  100. package/samples/schemas/context-contracts.schema.json +89 -0
  101. package/samples/schemas/conventions.schema.json +34 -0
  102. package/samples/schemas/decisions.schema.json +87 -0
  103. package/samples/schemas/features.schema.json +53 -0
  104. package/samples/schemas/framework-gaps.schema.json +64 -0
  105. package/samples/schemas/issues.schema.json +39 -0
  106. package/samples/schemas/layer-plans.schema.json +92 -0
  107. package/samples/schemas/phase.schema.json +118 -0
  108. package/samples/schemas/rationale.schema.json +24 -0
  109. package/samples/schemas/requirements.schema.json +34 -0
  110. package/samples/schemas/research.schema.json +134 -0
  111. package/samples/schemas/spec-reviews.schema.json +62 -0
  112. package/samples/schemas/story.schema.json +73 -0
  113. package/samples/schemas/tasks.schema.json +34 -0
  114. package/samples/schemas/verification.schema.json +37 -0
  115. package/schemas/bootstrap.schema.json +24 -0
  116. package/schemas/config.schema.json +193 -0
  117. package/schemas/layer.schema.json +9 -0
  118. package/schemas/priority.schema.json +9 -0
  119. package/schemas/relations.schema.json +31 -0
  120. package/schemas/severity.schema.json +9 -0
  121. package/schemas/source.schema.json +9 -0
  122. package/schemas/status.schema.json +9 -0
  123. package/schemas/verification-method.schema.json +9 -0
  124. package/skill-narrative.md +130 -0
  125. package/skills/pi-context/SKILL.md +684 -0
  126. package/skills/pi-context/references/bundled-resources.md +47 -0
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * Centralized read/write API for .project/*.json project block files.
3
+ * Validates data against schemas before writing; uses atomic writes (tmp + rename).
4
+ * Read-modify-write operations (append, update) use file-level locking via proper-lockfile
5
+ * to prevent data loss from concurrent workflow steps targeting the same block.
6
+ *
7
+ * DispatchContext (FGAP-004): every write function accepts an optional final
8
+ * argument `ctx?: DispatchContext`. When provided AND the target block's
9
+ * schema declares any of {created_by, created_at, modified_by, modified_at},
10
+ * items are stamped via `stampItem` from `./dispatch-context` before AJV
11
+ * validation. When `ctx` is undefined, behavior is byte-identical to the
12
+ * pre-step-3 surface — the parameter is purely additive. The has-author-fields
13
+ * decision is mtime-cached per (cwd, blockName) to avoid re-reading the
14
+ * schema on every write; mirrors the `loadContext` cache pattern from
15
+ * `context.ts`.
16
+ *
17
+ * Future extraction seam for pi-project extension.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import _lockfile from "proper-lockfile";
22
+ import { assertSubstrateName, resolveContextDir, schemaPath, tryResolveContextDir } from "./context-dir.js";
23
+ import { stampItem } from "./dispatch-context.js";
24
+ import { validateFromFile } from "./schema-validator.js";
25
+ // Node16 module resolution + CJS interop: default import may be wrapped
26
+ const lockfile = _lockfile.default ?? _lockfile;
27
+ /**
28
+ * Acquire a file-level lock, run fn(), release lock in finally.
29
+ * Skips locking if the target file does not yet exist (first write — no contention possible).
30
+ * Uses proper-lockfile's lockSync/unlockSync for synchronous read-modify-write safety.
31
+ */
32
+ function withBlockLock(filePath, fn) {
33
+ if (!fs.existsSync(filePath)) {
34
+ return fn();
35
+ }
36
+ lockfile.lockSync(filePath, { stale: 10000 });
37
+ try {
38
+ return fn();
39
+ }
40
+ finally {
41
+ lockfile.unlockSync(filePath);
42
+ }
43
+ }
44
+ function blockFilePath(cwd, blockName) {
45
+ assertSubstrateName(blockName);
46
+ return path.join(resolveContextDir(cwd), `${blockName}.json`);
47
+ }
48
+ function blockSchemaPath(cwd, blockName) {
49
+ return schemaPath(cwd, blockName);
50
+ }
51
+ // ── Schema introspection cache (DispatchContext support, FGAP-004) ───────────
52
+ /**
53
+ * Author fields recognized by `stampItem`. If the target schema's
54
+ * (top-level array item, or top-level object) `properties` declares any of
55
+ * these, ctx-stamping runs before AJV validation; otherwise stamping is
56
+ * skipped so an `additionalProperties: false` schema does not fail
57
+ * validation on injected fields.
58
+ */
59
+ const AUTHOR_FIELDS = ["created_by", "created_at", "modified_by", "modified_at"];
60
+ const schemaCache = new Map();
61
+ function safeStat(p) {
62
+ try {
63
+ return fs.statSync(p);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Subset of `AUTHOR_FIELDS` that the schema's TOP-LEVEL `properties` object
71
+ * declares. Used by `writeBlock`'s envelope-stamp question — whole-block
72
+ * writes stamp on the envelope, not on every nested item. Empty set = "no
73
+ * envelope-level author fields declared, skip stamping."
74
+ */
75
+ function schemaTopLevelDeclaredAuthorFields(schema) {
76
+ const out = new Set();
77
+ if (!schema || typeof schema !== "object")
78
+ return out;
79
+ const s = schema;
80
+ const props = s.properties;
81
+ if (!props)
82
+ return out;
83
+ for (const f of AUTHOR_FIELDS) {
84
+ if (Object.hasOwn(props, f))
85
+ out.add(f);
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * Walk every `items.properties` reachable from a schema by recursing through
91
+ * `properties.*` and `items.*`. For each visited array property, record
92
+ * whether its items' properties declare any author field. The result map is
93
+ * keyed by the array property name; nested arrays (e.g.
94
+ * `properties.reviews.items.properties.findings`) appear under their own key
95
+ * (`findings`) so the caller's question — "do the items of array
96
+ * '<arrayKey>' declare author fields?" — gets the right answer regardless of
97
+ * nesting depth. Mutates `into` in place.
98
+ *
99
+ * Recursion is bounded by the schema's structural depth — there is no risk
100
+ * of cycles unless the schema uses `$ref` cycles (which AJV would reject as
101
+ * malformed during data validation, and step-3 schemas use no $ref).
102
+ */
103
+ function collectArrayItemAuthorDecisions(schema, into) {
104
+ if (!schema || typeof schema !== "object")
105
+ return;
106
+ const s = schema;
107
+ const props = s.properties;
108
+ if (!props)
109
+ return;
110
+ for (const [propKey, propSpecRaw] of Object.entries(props)) {
111
+ if (!propSpecRaw || typeof propSpecRaw !== "object")
112
+ continue;
113
+ const spec = propSpecRaw;
114
+ if (spec.type === "array") {
115
+ const items = spec.items;
116
+ if (items && typeof items === "object") {
117
+ const itemProps = items.properties;
118
+ if (itemProps) {
119
+ const declared = new Set();
120
+ for (const f of AUTHOR_FIELDS) {
121
+ if (Object.hasOwn(itemProps, f))
122
+ declared.add(f);
123
+ }
124
+ // "Union" if the same key appears at multiple nesting
125
+ // depths — the recorded set is the union of declared
126
+ // fields across all reachable shapes for the key. This
127
+ // preserves the earlier "stamp on positive declaration
128
+ // if any reachable shape carries the field" behavior
129
+ // without losing the per-field grain.
130
+ const prior = into.get(propKey);
131
+ if (prior) {
132
+ const merged = new Set(prior);
133
+ for (const f of declared)
134
+ merged.add(f);
135
+ into.set(propKey, merged);
136
+ }
137
+ else {
138
+ into.set(propKey, declared);
139
+ }
140
+ // Recurse into items so deeper nested arrays are catalogued too.
141
+ collectArrayItemAuthorDecisions(items, into);
142
+ }
143
+ }
144
+ }
145
+ else {
146
+ // Recurse into nested object properties (covers `type: "object"`
147
+ // without a literal `type` field — JSON Schema permits omitting
148
+ // `type` so long as `properties` is present).
149
+ collectArrayItemAuthorDecisions(spec, into);
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * Load the cached schema-introspection answer for a schema file, refreshing
155
+ * the cache when the file's mtime changes (or it appears / disappears).
156
+ * Returns `null` when no schema exists at the path — callers treat that as
157
+ * "no author fields declared" and skip ctx-stamping silently.
158
+ *
159
+ * Cache key is the absolute schema path (globally unique across cwds and
160
+ * block names), enabling reuse for arbitrary (filePath, schemaPath) pairs
161
+ * outside `.project/` — e.g., monitor side-car list schemas resolved from
162
+ * `import.meta.url` in pi-behavior-monitors.
163
+ */
164
+ function getSchemaCacheEntry(schemaPath) {
165
+ if (!schemaPath)
166
+ return null;
167
+ const key = path.resolve(schemaPath);
168
+ const stat = safeStat(key);
169
+ if (!stat) {
170
+ // Missing schema — drop any stale cache entry and signal absence.
171
+ schemaCache.delete(key);
172
+ return null;
173
+ }
174
+ const mtimeMs = stat.mtimeMs;
175
+ const hit = schemaCache.get(key);
176
+ if (hit && hit.mtimeMs === mtimeMs)
177
+ return hit;
178
+ let schema;
179
+ try {
180
+ schema = JSON.parse(fs.readFileSync(key, "utf-8"));
181
+ }
182
+ catch {
183
+ // Unreadable / invalid JSON — treat as "no author fields declared" so
184
+ // stamping is skipped; the existing AJV validation path will surface the
185
+ // real problem on the next write.
186
+ const entry = {
187
+ mtimeMs,
188
+ hasAuthorFields: false,
189
+ envelopeDeclares: new Set(),
190
+ perArrayKey: new Map(),
191
+ };
192
+ schemaCache.set(key, entry);
193
+ return entry;
194
+ }
195
+ const perArrayKey = new Map();
196
+ collectArrayItemAuthorDecisions(schema, perArrayKey);
197
+ const envelopeDeclares = schemaTopLevelDeclaredAuthorFields(schema);
198
+ let anyArrayItemDeclares = false;
199
+ for (const v of perArrayKey.values()) {
200
+ if (v.size > 0) {
201
+ anyArrayItemDeclares = true;
202
+ break;
203
+ }
204
+ }
205
+ const entry = {
206
+ mtimeMs,
207
+ hasAuthorFields: envelopeDeclares.size > 0 || anyArrayItemDeclares,
208
+ envelopeDeclares,
209
+ perArrayKey,
210
+ };
211
+ schemaCache.set(key, entry);
212
+ return entry;
213
+ }
214
+ /**
215
+ * Subset of `AUTHOR_FIELDS` declared at the schema's top-level envelope.
216
+ * Used by `writeTypedFile` to decide which envelope fields to stamp on a
217
+ * whole-file write — distinct from per-item stamping which uses
218
+ * `declaredAuthorFieldsForArray`. Empty set = "no envelope-level stamping."
219
+ */
220
+ function declaredAuthorFieldsForEnvelope(schemaPath) {
221
+ const entry = getSchemaCacheEntry(schemaPath);
222
+ return entry?.envelopeDeclares ?? new Set();
223
+ }
224
+ /**
225
+ * Subset of `AUTHOR_FIELDS` declared on the items of the array reached by
226
+ * `arrayKey` — at any nesting depth. Used by the array-grained writers to
227
+ * thread the per-field stamping decision into `stampItem`. An array key not
228
+ * catalogued by the schema returns the empty set — stamping a key the schema
229
+ * does not describe is the path that triggers `additionalProperties: false`
230
+ * AJV failures, so the safe default is "skip" rather than "trust the envelope
231
+ * answer." Empty set = "no per-item stamping."
232
+ */
233
+ function declaredAuthorFieldsForArray(schemaPath, arrayKey) {
234
+ const entry = getSchemaCacheEntry(schemaPath);
235
+ if (!entry)
236
+ return new Set();
237
+ return entry.perArrayKey.get(arrayKey) ?? new Set();
238
+ }
239
+ /**
240
+ * Conditionally stamp `item` when `ctx` is provided AND the schema declares
241
+ * author fields on the relevant array items. Returns the (possibly stamped)
242
+ * item — callers downstream feed it to AJV validation as usual.
243
+ *
244
+ * `mode` tracks "is this a fresh insertion or a mutation of an existing
245
+ * item?" so `created_by` / `created_at` are not refreshed on update.
246
+ */
247
+ function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
248
+ if (!ctx)
249
+ return item;
250
+ const declared = declaredAuthorFieldsForArray(schemaPath, arrayKey);
251
+ if (declared.size === 0)
252
+ return item;
253
+ return stampItem(item, ctx, mode, declared);
254
+ }
255
+ /**
256
+ * Read and parse a .project/{blockName}.json file.
257
+ * Throws if the file does not exist or contains invalid JSON.
258
+ *
259
+ * Optional filter: when provided, returns a shallow copy of the block with only
260
+ * matching items in the specified array key. Non-array or missing keys return the
261
+ * block unchanged. The filter is applied after parsing, before returning.
262
+ */
263
+ export function readBlock(cwd, blockName, filter) {
264
+ const filePath = blockFilePath(cwd, blockName);
265
+ let content;
266
+ try {
267
+ content = fs.readFileSync(filePath, "utf-8");
268
+ }
269
+ catch {
270
+ throw new Error(`Block file not found: .project/${blockName}.json`);
271
+ }
272
+ let data;
273
+ try {
274
+ data = JSON.parse(content);
275
+ }
276
+ catch (err) {
277
+ const msg = err instanceof Error ? err.message : String(err);
278
+ throw new Error(`Invalid JSON in block file: .project/${blockName}.json: ${msg}`);
279
+ }
280
+ if (filter) {
281
+ const record = data;
282
+ const arr = record[filter.arrayKey];
283
+ if (Array.isArray(arr)) {
284
+ return { ...record, [filter.arrayKey]: arr.filter(filter.predicate) };
285
+ }
286
+ }
287
+ return data;
288
+ }
289
+ /**
290
+ * Resolve the existing schema path for a block (or null when no schema
291
+ * file is present at the conventional location). Used by every wrapper
292
+ * that delegates to the typed-file primitives.
293
+ */
294
+ function existingBlockSchemaPath(cwd, blockName) {
295
+ const schemaFile = blockSchemaPath(cwd, blockName);
296
+ return fs.existsSync(schemaFile) ? schemaFile : null;
297
+ }
298
+ /**
299
+ * Read raw file content as a parsed value, throwing labeled errors
300
+ * matching the prior `readBlock` semantics. Generalised so the typed-file
301
+ * primitives can read arbitrary paths (not only `.project/<name>.json`).
302
+ * `errorLabel` typically ends up like `block file '<name>.json'` or
303
+ * `monitor '<name>' patterns`.
304
+ */
305
+ function readTypedFile(filePath, errorLabel) {
306
+ let content;
307
+ try {
308
+ content = fs.readFileSync(filePath, "utf-8");
309
+ }
310
+ catch {
311
+ throw new Error(`File not found: ${errorLabel} (${filePath})`);
312
+ }
313
+ try {
314
+ return JSON.parse(content);
315
+ }
316
+ catch (err) {
317
+ const msg = err instanceof Error ? err.message : String(err);
318
+ throw new Error(`Invalid JSON in ${errorLabel} (${filePath}): ${msg}`);
319
+ }
320
+ }
321
+ /**
322
+ * Validated whole-file write to an arbitrary `(filePath, schemaPath)` pair.
323
+ * The `.project/`-targeting `writeBlock` becomes a thin wrapper over this.
324
+ * `schemaPath = null` skips AJV validation entirely (matches `writeBlock`'s
325
+ * "no schema file present" semantic).
326
+ *
327
+ * `ctx` (FGAP-004): when provided AND the schema declares author fields at
328
+ * the top-level envelope (and `data` is object-shaped, NOT array-shaped),
329
+ * the envelope is stamped before AJV runs. Top-level array files (e.g.
330
+ * monitor pattern lists) skip envelope-stamping silently — the envelope
331
+ * lookup returns an empty set for array schemas because their top-level
332
+ * `properties` is undefined.
333
+ *
334
+ * Atomic write via tmp + rename. Does NOT itself acquire a `withBlockLock`
335
+ * — whole-file overwrite has no read-modify-write race that locking would
336
+ * protect against. Callers performing read-modify-write (e.g.
337
+ * `appendToTypedFile`, `appendToBlock`, …) wrap the surrounding critical
338
+ * section in `withBlockLock` themselves; `writeTypedFile` is then called
339
+ * from inside that section. This matches the prior `writeBlock` /
340
+ * `appendToBlock` split exactly — preserves byte-identical lock semantics
341
+ * for existing callers.
342
+ */
343
+ export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
344
+ const label = errorLabel ?? filePath;
345
+ // Optional ctx-stamping: only when the schema declares author fields at
346
+ // the top-level envelope, and only on object-shaped data. Per-field
347
+ // declared subset is threaded into `stampItem` so partial declarations
348
+ // (e.g. envelope declares `created_by` only) do not inject the other
349
+ // three fields and trip `additionalProperties: false`.
350
+ let toWrite = data;
351
+ if (ctx && schemaPath && data && typeof data === "object" && !Array.isArray(data)) {
352
+ const declared = declaredAuthorFieldsForEnvelope(schemaPath);
353
+ if (declared.size > 0) {
354
+ toWrite = stampItem(data, ctx, "create", declared);
355
+ }
356
+ }
357
+ // Validate before write (if a schema is supplied)
358
+ if (schemaPath) {
359
+ validateFromFile(schemaPath, toWrite, label);
360
+ }
361
+ // Ensure directory exists
362
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
363
+ // Atomic write: tmp + rename. Callers needing read-modify-write atomicity
364
+ // hold `withBlockLock(filePath, ...)` around the broader critical section.
365
+ const tmpPath = `${filePath}.block-api-${process.pid}.tmp`;
366
+ try {
367
+ fs.writeFileSync(tmpPath, JSON.stringify(toWrite, null, 2), "utf-8");
368
+ fs.renameSync(tmpPath, filePath);
369
+ }
370
+ catch (err) {
371
+ try {
372
+ fs.unlinkSync(tmpPath);
373
+ }
374
+ catch {
375
+ /* ignore cleanup failure */
376
+ }
377
+ const msg = err instanceof Error ? err.message : String(err);
378
+ throw new Error(`Failed to write ${label}: ${msg}`);
379
+ }
380
+ }
381
+ /**
382
+ * Validated atomic append to an array within a `(filePath, schemaPath)` pair.
383
+ * `arrayPath = null` means "the file IS the array" (top-level array shape,
384
+ * e.g. monitor patterns / instructions); `arrayPath = string` means
385
+ * "data[arrayPath] is the target array" (object-with-array-field shape, the
386
+ * `.project/` block convention).
387
+ *
388
+ * `ctx` (FGAP-004): when provided AND the schema declares author fields on
389
+ * the items reached by `arrayPath` (or, for the flat-array case, on the
390
+ * array's `items.properties.*`), the appended item is stamped via
391
+ * `stampItem` in create-mode before AJV validation. For the flat-array
392
+ * case the array key passed to the per-array-key cache lookup is the
393
+ * arbitrary token `__top__` — `collectArrayItemAuthorDecisions` only
394
+ * traverses `properties.*` paths and never visits a top-level array
395
+ * schema, so the lookup will always miss; flat-array stamping is
396
+ * intentionally a no-op until a schema actually declares author fields
397
+ * on a top-level array shape (no current consumer does so).
398
+ */
399
+ export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, errorLabel) {
400
+ const label = errorLabel ?? filePath;
401
+ withBlockLock(filePath, () => {
402
+ const data = readTypedFile(filePath, label);
403
+ if (arrayPath === null) {
404
+ // Flat top-level array shape: file content IS the array.
405
+ if (!Array.isArray(data)) {
406
+ throw new Error(`${label}: expected top-level array, got ${typeof data}`);
407
+ }
408
+ const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
409
+ ? maybeStampItem(schemaPath, "__top__", item, ctx, "create")
410
+ : item;
411
+ const next = [...data, itemToAppend];
412
+ // Validate the WHOLE array against schemaPath, then write.
413
+ writeTypedFile(filePath, schemaPath, next, undefined, label);
414
+ return;
415
+ }
416
+ // Object-with-array-field shape (the .project/ block convention).
417
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
418
+ throw new Error(`${label}: expected object with array field '${arrayPath}'`);
419
+ }
420
+ const record = data;
421
+ if (!(arrayPath in record)) {
422
+ throw new Error(`${label} has no key '${arrayPath}'`);
423
+ }
424
+ if (!Array.isArray(record[arrayPath])) {
425
+ throw new Error(`${label} key '${arrayPath}' is not an array`);
426
+ }
427
+ const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
428
+ ? maybeStampItem(schemaPath, arrayPath, item, ctx, "create")
429
+ : item;
430
+ record[arrayPath] = [...record[arrayPath], itemToAppend];
431
+ writeTypedFile(filePath, schemaPath, record, undefined, label);
432
+ });
433
+ }
434
+ /**
435
+ * Validated atomic bulk append-if-absent to an array within a
436
+ * `(filePath, schemaPath)` pair. Each candidate in `items` is appended only
437
+ * when no element already present (on-disk OR earlier in this same batch)
438
+ * shares its `matchKey`. Dedup is keyed solely on `matchKey(item)` — the
439
+ * write surface does no semantic validation beyond the whole-array AJV check
440
+ * and this exact-duplicate-no-op; richer integrity (referential, etc.) is
441
+ * deferred to callers / downstream validators.
442
+ *
443
+ * `arrayPath` mirrors `appendToTypedFile`: `null` ⇒ the file content IS the
444
+ * array (flat top-level array shape); `string` ⇒ `data[arrayPath]` is the
445
+ * target array (object-with-array-field shape, the `.project/` convention).
446
+ * The branching is identical to `appendToTypedFile`'s.
447
+ *
448
+ * The whole read-find-write critical section runs inside `withBlockLock`, so
449
+ * concurrent batches against the same file serialise. When `appended === 0`
450
+ * nothing is written (the file is left byte-identical). The first write
451
+ * against an absent file works: `withBlockLock` skips locking when the file is
452
+ * absent (no contention possible), and the absent file is treated as an empty
453
+ * array for the flat-array shape — only that shape is creatable from absence;
454
+ * the object-with-array-field shape requires an existing envelope and so still
455
+ * throws via `readTypedFile`.
456
+ *
457
+ * `ctx` is threaded to `writeTypedFile` for attestation parity; per the
458
+ * top-level-array stamping semantics documented above, flat-array shapes with
459
+ * no declared envelope author fields treat it as a structural no-op.
460
+ */
461
+ export function appendManyToTypedFileIfAbsent(filePath, schemaPath, arrayPath, items, matchKey, ctx, errorLabel) {
462
+ const label = errorLabel ?? filePath;
463
+ return withBlockLock(filePath, () => {
464
+ // Absent file is the empty-array starting point for the flat-array shape
465
+ // (enables first-write file creation); the object-with-array-field shape
466
+ // requires an existing envelope, so it reads through and throws below.
467
+ const data = arrayPath === null && !fs.existsSync(filePath) ? [] : readTypedFile(filePath, label);
468
+ if (arrayPath === null) {
469
+ // Flat top-level array shape: file content IS the array.
470
+ if (!Array.isArray(data)) {
471
+ throw new Error(`${label}: expected top-level array, got ${typeof data}`);
472
+ }
473
+ const arr = [...data];
474
+ const seen = new Set(arr.map((existing) => matchKey(existing)));
475
+ let appended = 0;
476
+ let skipped = 0;
477
+ for (const candidate of items) {
478
+ const key = matchKey(candidate);
479
+ if (seen.has(key)) {
480
+ skipped++;
481
+ continue;
482
+ }
483
+ seen.add(key);
484
+ arr.push(candidate);
485
+ appended++;
486
+ }
487
+ if (appended > 0) {
488
+ writeTypedFile(filePath, schemaPath, arr, ctx, label);
489
+ }
490
+ return { appended, skipped };
491
+ }
492
+ // Object-with-array-field shape (the .project/ block convention).
493
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
494
+ throw new Error(`${label}: expected object with array field '${arrayPath}'`);
495
+ }
496
+ const record = data;
497
+ if (!(arrayPath in record)) {
498
+ throw new Error(`${label} has no key '${arrayPath}'`);
499
+ }
500
+ if (!Array.isArray(record[arrayPath])) {
501
+ throw new Error(`${label} key '${arrayPath}' is not an array`);
502
+ }
503
+ const arr = [...record[arrayPath]];
504
+ const seen = new Set(arr.map((existing) => matchKey(existing)));
505
+ let appended = 0;
506
+ let skipped = 0;
507
+ for (const candidate of items) {
508
+ const key = matchKey(candidate);
509
+ if (seen.has(key)) {
510
+ skipped++;
511
+ continue;
512
+ }
513
+ seen.add(key);
514
+ arr.push(candidate);
515
+ appended++;
516
+ }
517
+ if (appended > 0) {
518
+ record[arrayPath] = arr;
519
+ writeTypedFile(filePath, schemaPath, record, ctx, label);
520
+ }
521
+ return { appended, skipped };
522
+ });
523
+ }
524
+ // ── Internal helpers shared by the typed-file find-or-merge primitives ─────
525
+ /**
526
+ * Resolve the array reachable by `arrayPath` against `data` after the
527
+ * standard read step, throwing labeled errors for the three structural
528
+ * invariants common to every find-or-merge primitive:
529
+ * - `arrayPath === null` → `data` itself must be a plain array
530
+ * - `arrayPath === string` → `data` must be a non-array object with that
531
+ * key present and the value must be an array
532
+ *
533
+ * Returns the resolved array PLUS a writer thunk that puts the (possibly
534
+ * new) array reference back into the parent shape so callers can hand the
535
+ * mutated parent to `writeTypedFile`. The writer thunk is what isolates
536
+ * the "file IS the array" case from the "object with array field" case
537
+ * inside primitive bodies — every primitive uses the same shape-resolution
538
+ * preamble and the same final `writeTypedFile` call regardless of the
539
+ * top-level shape.
540
+ *
541
+ * Multi-match warnings, predicate-not-found semantics, and ctx-stamping
542
+ * are NOT part of this helper — they vary per primitive (update throws on
543
+ * miss, remove is idempotent, upsert decides append-vs-replace, etc.).
544
+ */
545
+ function resolveTypedArrayShape(data, arrayPath, label) {
546
+ if (arrayPath === null) {
547
+ if (!Array.isArray(data)) {
548
+ throw new Error(`${label}: expected top-level array, got ${typeof data}`);
549
+ }
550
+ return {
551
+ arr: data,
552
+ rewriteParent: (next) => next,
553
+ };
554
+ }
555
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
556
+ throw new Error(`${label}: expected object with array field '${arrayPath}'`);
557
+ }
558
+ const record = data;
559
+ if (!(arrayPath in record)) {
560
+ throw new Error(`${label} has no key '${arrayPath}'`);
561
+ }
562
+ const candidate = record[arrayPath];
563
+ if (!Array.isArray(candidate)) {
564
+ throw new Error(`${label} key '${arrayPath}' is not an array`);
565
+ }
566
+ return {
567
+ arr: candidate,
568
+ rewriteParent: (next) => ({ ...record, [arrayPath]: next }),
569
+ };
570
+ }
571
+ /**
572
+ * Per-item ctx-stamping for the typed-file find-or-merge primitives.
573
+ * Mirrors the existing `maybeStampItem` semantics but keys the schema
574
+ * lookup by `arrayPath`. For the flat-array case (`arrayPath === null`)
575
+ * the lookup uses the `__top__` sentinel which `collectArrayItemAuthorDecisions`
576
+ * never populates — flat-array stamping is intentionally a no-op until a
577
+ * schema actually declares author fields on a top-level array shape (no
578
+ * current consumer does so). Documented in the FGAP-019 closure commit.
579
+ */
580
+ function maybeStampTypedItem(schemaPath, arrayPath, item, ctx, mode) {
581
+ const key = arrayPath ?? "__top__";
582
+ return maybeStampItem(schemaPath, key, item, ctx, mode);
583
+ }
584
+ /**
585
+ * Validated atomic find-by-predicate update of one item inside an array
586
+ * reachable from `(filePath, schemaPath, arrayPath)`. Generalises
587
+ * `updateItemInBlock` to arbitrary file paths and supports both top-level
588
+ * array files (`arrayPath === null`) and object-with-array-field files
589
+ * (`arrayPath === string`). Throws on predicate miss; AJV validates the
590
+ * whole file after mutation.
591
+ *
592
+ * Multi-match warning emits on stderr with the established `[block-api]`
593
+ * prefix (grep-discoverable across legacy log lines).
594
+ *
595
+ * `ctx`: when supplied AND the schema declares author fields on the items
596
+ * reachable by `arrayPath`, the merged item is stamped in update-mode
597
+ * before AJV runs. Flat-array stamping is a no-op (see
598
+ * `maybeStampTypedItem`).
599
+ */
600
+ export function updateItemInTypedFile(filePath, schemaPath, arrayPath, predicate, updates, ctx, errorLabel) {
601
+ const label = errorLabel ?? filePath;
602
+ withBlockLock(filePath, () => {
603
+ const data = readTypedFile(filePath, label);
604
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
605
+ const arr = rawArr;
606
+ const idx = arr.findIndex(predicate);
607
+ if (idx === -1) {
608
+ throw new Error(`No matching item in ${label}${arrayPath !== null ? ` key '${arrayPath}'` : ""}`);
609
+ }
610
+ let matchCount = 1;
611
+ for (let i = idx + 1; i < arr.length; i++) {
612
+ if (predicate(arr[i]))
613
+ matchCount++;
614
+ }
615
+ if (matchCount > 1) {
616
+ console.error(`[block-api] updateItemInBlock: ${matchCount} items matched predicate, only first updated`);
617
+ }
618
+ const merged = { ...arr[idx], ...updates };
619
+ const updated = ctx ? maybeStampTypedItem(schemaPath, arrayPath, merged, ctx, "update") : merged;
620
+ const patched = [...arr];
621
+ patched[idx] = updated;
622
+ writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
623
+ });
624
+ }
625
+ /**
626
+ * Validated atomic find-or-append. Generalises `upsertItemInBlock` to
627
+ * arbitrary `(filePath, schemaPath, arrayPath)` triples, including
628
+ * top-level array files via `arrayPath === null`.
629
+ *
630
+ * FGAP-018 fix lives here (was in `upsertItemInBlock` prior to Step 6.3):
631
+ * on the update branch, declared create-time attestation fields are
632
+ * pre-merged from the existing on-disk item onto the supplied item if
633
+ * absent, so attestation integrity (FGAP-004) holds across replacement.
634
+ * The wrapper `upsertItemInBlock` inherits the fix structurally.
635
+ *
636
+ * For the flat-array case the pre-merge is a structural no-op — the
637
+ * `declaredAuthorFieldsForArray` lookup keyed by `__top__` returns the
638
+ * empty set (top-level array schemas are never visited by
639
+ * `collectArrayItemAuthorDecisions`), so no fields are carried. This is
640
+ * intentional: no current consumer declares author fields on a top-level
641
+ * array shape, and the unconditional pre-merge code path simply finds
642
+ * nothing to merge. If a future consumer lands such a schema, the
643
+ * stamping cataloguer needs an extension (filed-or-future work) — this
644
+ * primitive will then start preserving attestation on flat-array upserts
645
+ * automatically.
646
+ */
647
+ export function upsertItemInTypedFile(filePath, schemaPath, arrayPath, item, idField, ctx, errorLabel) {
648
+ const label = errorLabel ?? filePath;
649
+ const idValue = item[idField];
650
+ if (idValue === undefined || idValue === null || idValue === "") {
651
+ throw new Error(`upsertItemInTypedFile: item is missing required idField '${idField}' (got: ${JSON.stringify(idValue)}) for ${label}`);
652
+ }
653
+ return withBlockLock(filePath, () => {
654
+ const data = readTypedFile(filePath, label);
655
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
656
+ const arr = rawArr;
657
+ const idx = arr.findIndex((existing) => existing && existing[idField] === idValue);
658
+ const mode = idx === -1 ? "appended" : "updated";
659
+ const stampMode = mode === "appended" ? "create" : "update";
660
+ // FGAP-018 fix: on update branch, pre-merge create-time attestation fields
661
+ // from the existing on-disk item onto the supplied item if absent. stampItem
662
+ // in update-mode does not touch created_*; this carries them forward across
663
+ // replacement so attestation integrity (FGAP-004) holds. For the flat-array
664
+ // case (arrayPath === null) the declared-fields lookup returns an empty set
665
+ // and no carry happens — see the function-doc note.
666
+ let itemForStamp = item;
667
+ if (idx !== -1 && schemaPath) {
668
+ const declared = declaredAuthorFieldsForArray(schemaPath, arrayPath ?? "__top__");
669
+ const existing = arr[idx];
670
+ const carriedFields = {};
671
+ for (const field of ["created_by", "created_at"]) {
672
+ if (declared.has(field) && !(field in item) && existing && field in existing) {
673
+ carriedFields[field] = existing[field];
674
+ }
675
+ }
676
+ if (Object.keys(carriedFields).length > 0) {
677
+ itemForStamp = { ...carriedFields, ...item };
678
+ }
679
+ }
680
+ const stamped = ctx ? maybeStampTypedItem(schemaPath, arrayPath, itemForStamp, ctx, stampMode) : itemForStamp;
681
+ const patched = [...arr];
682
+ if (idx === -1) {
683
+ patched.push(stamped);
684
+ }
685
+ else {
686
+ patched[idx] = stamped;
687
+ }
688
+ writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
689
+ return { mode };
690
+ });
691
+ }
692
+ /**
693
+ * Validated atomic predicate-based remove. Generalises `removeFromBlock`
694
+ * to arbitrary `(filePath, schemaPath, arrayPath)` triples including
695
+ * top-level array files. Idempotent on miss (returns `{ removed: 0 }`
696
+ * without throwing or writing). AJV validates whole file after mutation
697
+ * (so e.g. a `minItems` violation surfaces).
698
+ */
699
+ export function removeFromTypedFile(filePath, schemaPath, arrayPath, predicate, ctx, errorLabel) {
700
+ // See note in removeFromBlock: ctx is accepted for surface parity; no items
701
+ // remain to stamp on removal.
702
+ void ctx;
703
+ const label = errorLabel ?? filePath;
704
+ return withBlockLock(filePath, () => {
705
+ const data = readTypedFile(filePath, label);
706
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, arrayPath, label);
707
+ const arr = rawArr;
708
+ const remaining = arr.filter((it) => !predicate(it));
709
+ const removed = arr.length - remaining.length;
710
+ if (removed === 0) {
711
+ return { removed: 0 };
712
+ }
713
+ if (removed > 1) {
714
+ console.error(`[block-api] removeFromBlock: ${removed} items matched predicate, all removed`);
715
+ }
716
+ writeTypedFile(filePath, schemaPath, rewriteParent(remaining), undefined, label);
717
+ return { removed };
718
+ });
719
+ }
720
+ /**
721
+ * Validated atomic append to a nested array inside a parent-array item.
722
+ * Generalises `appendToNestedArray` to arbitrary `(filePath, schemaPath,
723
+ * parentArrayKey, nestedArrayKey)`. Nesting requires object-with-array-field
724
+ * shape — a top-level array file cannot host nested arrays at the same
725
+ * structural level — so `parentArrayKey` is `string` (no `null` form).
726
+ *
727
+ * Throws on missing parent key, no parent match, missing nested key, or
728
+ * AJV failure. Multi-match warning emits at parent level via stderr with
729
+ * the established `[block-api]` prefix.
730
+ */
731
+ export function appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, errorLabel) {
732
+ const label = errorLabel ?? filePath;
733
+ withBlockLock(filePath, () => {
734
+ const data = readTypedFile(filePath, label);
735
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
736
+ const arr = rawArr;
737
+ const idx = arr.findIndex(predicate);
738
+ if (idx === -1) {
739
+ throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
740
+ }
741
+ let matchCount = 1;
742
+ for (let i = idx + 1; i < arr.length; i++) {
743
+ if (predicate(arr[i]))
744
+ matchCount++;
745
+ }
746
+ if (matchCount > 1) {
747
+ console.error(`[block-api] appendToNestedArray: ${matchCount} items matched predicate, only first updated`);
748
+ }
749
+ const parent = arr[idx];
750
+ if (!(nestedArrayKey in parent)) {
751
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
752
+ }
753
+ if (!Array.isArray(parent[nestedArrayKey])) {
754
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
755
+ }
756
+ const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
757
+ ? maybeStampItem(schemaPath, nestedArrayKey, item, ctx, "create")
758
+ : item;
759
+ const updatedParent = {
760
+ ...parent,
761
+ [nestedArrayKey]: [...parent[nestedArrayKey], itemToAppend],
762
+ };
763
+ const patched = [...arr];
764
+ patched[idx] = updatedParent;
765
+ writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
766
+ });
767
+ }
768
+ /**
769
+ * Validated atomic update of a single item inside a nested array on a
770
+ * parent-array item. Generalises `updateNestedArrayItem` to arbitrary
771
+ * `(filePath, schemaPath, parentArrayKey, nestedArrayKey)`. Object-shape
772
+ * file required (see `appendToNestedTypedFile` doc).
773
+ *
774
+ * Throws on missing parent key, no parent/nested match, missing nested
775
+ * key, or AJV failure. Multi-match warnings emit at both parent and
776
+ * nested levels via stderr with the `[block-api]` prefix.
777
+ */
778
+ export function updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, errorLabel) {
779
+ const label = errorLabel ?? filePath;
780
+ withBlockLock(filePath, () => {
781
+ const data = readTypedFile(filePath, label);
782
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
783
+ const arr = rawArr;
784
+ const parentIdx = arr.findIndex(parentPredicate);
785
+ if (parentIdx === -1) {
786
+ throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
787
+ }
788
+ let parentMatchCount = 1;
789
+ for (let i = parentIdx + 1; i < arr.length; i++) {
790
+ if (parentPredicate(arr[i]))
791
+ parentMatchCount++;
792
+ }
793
+ if (parentMatchCount > 1) {
794
+ console.error(`[block-api] updateNestedArrayItem: ${parentMatchCount} parent items matched predicate, only first updated`);
795
+ }
796
+ const parent = arr[parentIdx];
797
+ if (!(nestedArrayKey in parent)) {
798
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
799
+ }
800
+ if (!Array.isArray(parent[nestedArrayKey])) {
801
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
802
+ }
803
+ const nestedArr = parent[nestedArrayKey];
804
+ const nestedIdx = nestedArr.findIndex(nestedPredicate);
805
+ if (nestedIdx === -1) {
806
+ throw new Error(`No matching nested item in ${label} key '${parentArrayKey}[${parentIdx}].${nestedArrayKey}'`);
807
+ }
808
+ let nestedMatchCount = 1;
809
+ for (let i = nestedIdx + 1; i < nestedArr.length; i++) {
810
+ if (nestedPredicate(nestedArr[i]))
811
+ nestedMatchCount++;
812
+ }
813
+ if (nestedMatchCount > 1) {
814
+ console.error(`[block-api] updateNestedArrayItem: ${nestedMatchCount} nested items matched predicate, only first updated`);
815
+ }
816
+ const mergedNested = { ...nestedArr[nestedIdx], ...updates };
817
+ const updatedNested = ctx ? maybeStampItem(schemaPath, nestedArrayKey, mergedNested, ctx, "update") : mergedNested;
818
+ const patchedNested = [...nestedArr];
819
+ patchedNested[nestedIdx] = updatedNested;
820
+ const updatedParent = { ...parent, [nestedArrayKey]: patchedNested };
821
+ const patchedParents = [...arr];
822
+ patchedParents[parentIdx] = updatedParent;
823
+ writeTypedFile(filePath, schemaPath, rewriteParent(patchedParents), undefined, label);
824
+ });
825
+ }
826
+ /**
827
+ * Validated atomic remove from a nested array. Generalises
828
+ * `removeFromNestedArray` to arbitrary `(filePath, schemaPath,
829
+ * parentArrayKey, nestedArrayKey)`. Object-shape file required (see
830
+ * `appendToNestedTypedFile` doc). Idempotent on nested-miss (returns
831
+ * `{ removed: 0 }`); throws on parent-miss to surface a malformed
832
+ * caller, mirroring the wrapper's prior semantics.
833
+ */
834
+ export function removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, errorLabel) {
835
+ void ctx;
836
+ const label = errorLabel ?? filePath;
837
+ return withBlockLock(filePath, () => {
838
+ const data = readTypedFile(filePath, label);
839
+ const { arr: rawArr, rewriteParent } = resolveTypedArrayShape(data, parentArrayKey, label);
840
+ const arr = rawArr;
841
+ const parentIdx = arr.findIndex(parentPredicate);
842
+ if (parentIdx === -1) {
843
+ throw new Error(`No matching item in ${label} key '${parentArrayKey}'`);
844
+ }
845
+ let parentMatchCount = 1;
846
+ for (let i = parentIdx + 1; i < arr.length; i++) {
847
+ if (parentPredicate(arr[i]))
848
+ parentMatchCount++;
849
+ }
850
+ if (parentMatchCount > 1) {
851
+ console.error(`[block-api] removeFromNestedArray: ${parentMatchCount} parent items matched predicate, only first targeted`);
852
+ }
853
+ const parent = arr[parentIdx];
854
+ if (!(nestedArrayKey in parent)) {
855
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' has no nested key '${nestedArrayKey}'`);
856
+ }
857
+ if (!Array.isArray(parent[nestedArrayKey])) {
858
+ throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
859
+ }
860
+ const nestedArr = parent[nestedArrayKey];
861
+ const nestedRemaining = nestedArr.filter((it) => !nestedPredicate(it));
862
+ const removed = nestedArr.length - nestedRemaining.length;
863
+ if (removed === 0) {
864
+ return { removed: 0 };
865
+ }
866
+ if (removed > 1) {
867
+ console.error(`[block-api] removeFromNestedArray: ${removed} nested items matched predicate, all removed`);
868
+ }
869
+ const updatedParent = { ...parent, [nestedArrayKey]: nestedRemaining };
870
+ const patched = [...arr];
871
+ patched[parentIdx] = updatedParent;
872
+ writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
873
+ return { removed };
874
+ });
875
+ }
876
+ /**
877
+ * Validate `data` against its schema (if one exists) and write atomically
878
+ * to `.project/{blockName}.json`. Throws `ValidationError` on schema failure.
879
+ * Files without a corresponding schema are written without validation.
880
+ *
881
+ * Thin wrapper over `writeTypedFile` — see that function for full semantics.
882
+ * `ctx` (FGAP-004): whole-block writes are treated as create-mode envelope
883
+ * stamping; callers wanting per-item attribution should prefer the
884
+ * array-grained writers.
885
+ */
886
+ export function writeBlock(cwd, blockName, data, ctx) {
887
+ const filePath = blockFilePath(cwd, blockName);
888
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
889
+ writeTypedFile(filePath, schemaPath, data, ctx, `block file '${blockName}.json'`);
890
+ }
891
+ /**
892
+ * Read current file, push item onto data[arrayKey], validate whole file
893
+ * against schema, write atomically. Throws if file doesn't exist, if
894
+ * arrayKey is missing or not an array, or if validation fails.
895
+ *
896
+ * `ctx` (FGAP-004): when provided AND the schema declares author fields on
897
+ * `properties.<arrayKey>.items.properties.*`, the appended item is stamped
898
+ * via `stampItem` in create-mode before AJV validation. Schemas that don't
899
+ * declare author fields fall through unstamped — guards against
900
+ * `additionalProperties: false` AJV failures on blocks whose item shape
901
+ * doesn't carry author markers yet.
902
+ */
903
+ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
904
+ withBlockLock(blockFilePath(cwd, blockName), () => {
905
+ const data = readBlock(cwd, blockName);
906
+ if (!data || typeof data !== "object") {
907
+ throw new Error(`Block '${blockName}' is not an object`);
908
+ }
909
+ const record = data;
910
+ if (!(arrayKey in record)) {
911
+ throw new Error(`Block '${blockName}' has no key '${arrayKey}'`);
912
+ }
913
+ if (!Array.isArray(record[arrayKey])) {
914
+ throw new Error(`Block '${blockName}' key '${arrayKey}' is not an array`);
915
+ }
916
+ // Optional ctx-stamping for object-shaped items (skipped silently for
917
+ // scalar items even when the schema technically permits author fields —
918
+ // stamping a string / number is meaningless).
919
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
920
+ const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
921
+ ? maybeStampItem(schemaPath, arrayKey, item, ctx, "create")
922
+ : item;
923
+ record[arrayKey] = [...record[arrayKey], itemToAppend];
924
+ writeBlock(cwd, blockName, record);
925
+ });
926
+ }
927
+ /**
928
+ * Find an item in data[arrayKey] by predicate, shallow-merge updates onto it,
929
+ * validate whole file against schema, write atomically. Throws if no item
930
+ * matches, if arrayKey is missing or not an array, or if validation fails.
931
+ *
932
+ * `ctx` (FGAP-004): when provided AND the schema declares author fields on
933
+ * the array's items, the merged item is run through `stampItem` in
934
+ * update-mode after the shallow merge — `created_by` / `created_at` are
935
+ * preserved, `modified_by` / `modified_at` refresh.
936
+ */
937
+ export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates, ctx) {
938
+ const filePath = blockFilePath(cwd, blockName);
939
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
940
+ updateItemInTypedFile(filePath, schemaPath, arrayKey, predicate, updates, ctx, `block file '${blockName}.json'`);
941
+ }
942
+ /**
943
+ * Atomically find-or-append a single item in `data[arrayKey]` keyed by
944
+ * `idField`. Acquires the block lock; reads the array; locates the first
945
+ * existing item where `existingItem[idField] === item[idField]`; if found
946
+ * the item at that index is REPLACED (not shallow-merged) by the supplied
947
+ * `item` and stamping runs in `"update"` mode; otherwise `item` is pushed
948
+ * onto the array and stamping runs in `"create"` mode. AJV validates the
949
+ * whole file against the schema after mutation, before write.
950
+ *
951
+ * Composing existing `updateItemInBlock` + `appendToBlock` from a caller
952
+ * would release the block lock between the read-check and the mutating
953
+ * write, which is race-prone for concurrent monitor/LLM writes against the
954
+ * same block — this primitive holds the lock for both halves of the
955
+ * find-or-append decision in one atomic critical section.
956
+ *
957
+ * Throws when `item[idField]` is missing or empty (defensive — surfaces a
958
+ * malformed call site early instead of silently appending a duplicate
959
+ * that would never match on a subsequent upsert). Throws on the usual
960
+ * block / arrayKey / not-array invariants and on AJV validation failure.
961
+ *
962
+ * Replacement semantics (vs. updateItemInBlock's shallow-merge): upsert is
963
+ * the call surface for monitor write-actions where the template produces
964
+ * the FULL item shape per classification — there is no prior partial state
965
+ * to merge against. Callers that need merge-on-update should continue to
966
+ * use `updateItemInBlock`.
967
+ */
968
+ export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx) {
969
+ const filePath = blockFilePath(cwd, blockName);
970
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
971
+ return upsertItemInTypedFile(filePath, schemaPath, arrayKey, item, idField, ctx, `block file '${blockName}.json'`);
972
+ }
973
+ /**
974
+ * Atomically append an item to a nested array inside a parent-array item.
975
+ *
976
+ * Read current file, locate parent-array item by predicate, push `item` onto
977
+ * `data[parentArrayKey][matchedIndex][nestedArrayKey]`, validate whole file
978
+ * against schema, write atomically. Throws if file doesn't exist; if
979
+ * parentArrayKey is missing or not an array; if no parent item matches the
980
+ * predicate; if the matched parent item has no `nestedArrayKey` or it is not
981
+ * an array; or if validation fails. Mirrors updateItemInBlock's structure:
982
+ * file lock, predicate findIndex, multi-match warning, clone-before-write so
983
+ * the original array remains unmodified if writeBlock throws.
984
+ */
985
+ export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
986
+ const filePath = blockFilePath(cwd, blockName);
987
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
988
+ appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, `block file '${blockName}.json'`);
989
+ }
990
+ /**
991
+ * Atomically update a single item inside a nested array on a parent-array
992
+ * item: locate parent by parentPredicate, locate nested by nestedPredicate,
993
+ * shallow-merge `updates` onto the matched nested item, validate the whole
994
+ * file against schema, write atomically. Throws on missing block / missing
995
+ * parent key / parent key not array / no parent match / matched parent
996
+ * missing nestedKey / nested key not array / no nested match / validation
997
+ * failure. Multi-match warnings emit at both parent and nested levels via
998
+ * console.error (mirrors the established appendToNestedArray /
999
+ * updateItemInBlock convention). Clone-then-write keeps the original arrays
1000
+ * unmodified if writeBlock throws.
1001
+ */
1002
+ export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
1003
+ const filePath = blockFilePath(cwd, blockName);
1004
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
1005
+ updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, `block file '${blockName}.json'`);
1006
+ }
1007
+ /**
1008
+ * Atomically remove all items matching `predicate` from a top-level array
1009
+ * inside `data[arrayKey]`, validate the whole file against schema, write
1010
+ * atomically. Returns `{ removed: <count> }`. Throws on missing block /
1011
+ * missing key / key not array / validation failure (e.g., schema requires
1012
+ * minItems and removal violates it). Returns `{ removed: 0 }` on no match
1013
+ * without throwing — removal of a non-existent item is treated as an
1014
+ * idempotent successful no-op, distinct from update which throws on miss.
1015
+ */
1016
+ export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
1017
+ const filePath = blockFilePath(cwd, blockName);
1018
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
1019
+ return removeFromTypedFile(filePath, schemaPath, arrayKey, predicate, ctx, `block file '${blockName}.json'`);
1020
+ }
1021
+ /**
1022
+ * Atomically remove all items matching `nestedPredicate` from a nested array
1023
+ * inside the parent-array item matched by `parentPredicate`. Validates and
1024
+ * writes atomically. Returns `{ removed: <count> }`. Throws on missing block
1025
+ * / missing parent key / parent key not array / no parent match / matched
1026
+ * parent missing nestedKey / nested key not array / validation failure.
1027
+ * Returns `{ removed: 0 }` on no nested match without throwing (idempotent,
1028
+ * mirrors removeFromBlock semantics). Multi-match warning at parent level
1029
+ * via console.error.
1030
+ */
1031
+ export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
1032
+ const filePath = blockFilePath(cwd, blockName);
1033
+ const schemaPath = existingBlockSchemaPath(cwd, blockName);
1034
+ return removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, `block file '${blockName}.json'`);
1035
+ }
1036
+ /**
1037
+ * Read all `.json` files in a `.project/<subdir>/` directory and return the
1038
+ * parsed contents as a sorted array. Sort order is filesystem-name ascending
1039
+ * (matches Array.sort default on the basename strings). Missing directories
1040
+ * return `[]` — on-demand `.project/` subdirectories are valid and represent
1041
+ * "no items yet". Throws on filesystem read failure of a present file or on
1042
+ * invalid JSON, with file-relative path in the error message. Behavior must
1043
+ * match the previous private `executeReadDir` in pi-workflows step-block.ts
1044
+ * byte-identically; both pi-workflows and the `read-block-dir` registered
1045
+ * tool consume this single export.
1046
+ */
1047
+ export function readBlockDir(cwd, subdir) {
1048
+ const root = tryResolveContextDir(cwd);
1049
+ if (root === null)
1050
+ return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
1051
+ const dirPath = path.join(root, subdir);
1052
+ let entries;
1053
+ try {
1054
+ entries = fs
1055
+ .readdirSync(dirPath)
1056
+ .filter((f) => f.endsWith(".json"))
1057
+ .sort();
1058
+ }
1059
+ catch {
1060
+ // Missing directory = "no items yet" for on-demand substrate-dir subdirectories
1061
+ return [];
1062
+ }
1063
+ const results = [];
1064
+ for (const filename of entries) {
1065
+ const filePath = path.join(dirPath, filename);
1066
+ let content;
1067
+ try {
1068
+ content = fs.readFileSync(filePath, "utf-8");
1069
+ }
1070
+ catch {
1071
+ throw new Error(`Cannot read file: .project/${subdir}/${filename}`);
1072
+ }
1073
+ try {
1074
+ results.push(JSON.parse(content));
1075
+ }
1076
+ catch {
1077
+ throw new Error(`Invalid JSON in: .project/${subdir}/${filename}`);
1078
+ }
1079
+ }
1080
+ return results;
1081
+ }
1082
+ // ── Item-schema resolution + id allocation (FGAP-083 / FGAP-084) ──────────────
1083
+ /**
1084
+ * Resolve the item subschema for a block schema: find the first array property
1085
+ * carrying `items`, dereferencing a single-level `$ref` to `#/definitions/<x>`
1086
+ * or `#/$defs/<x>` (the shape FK-stripped block schemas use, e.g. features /
1087
+ * spec-reviews). Returns the array key + the resolved item schema object (with
1088
+ * its `properties` / `required` / `id`). Throws when no array property is found
1089
+ * or the `$ref` cannot be resolved.
1090
+ *
1091
+ * FGAP-083: callers that read `items.properties.id.pattern` / `items.required`
1092
+ * straight off `props[arrayKey].items` get `undefined` for `$ref` items; this
1093
+ * one dereference is the fix shared by auto-id, author-field auto-stamp, and
1094
+ * whole-file validation.
1095
+ */
1096
+ export function resolveBlockItemSchema(schema) {
1097
+ const props = (schema.properties ?? {});
1098
+ let arrayKey;
1099
+ for (const [k, v] of Object.entries(props)) {
1100
+ if (v && v.type === "array" && v.items) {
1101
+ arrayKey = k;
1102
+ break;
1103
+ }
1104
+ }
1105
+ if (!arrayKey) {
1106
+ throw new Error("resolveBlockItemSchema: no array property with items found in schema");
1107
+ }
1108
+ let items = props[arrayKey].items;
1109
+ const ref = typeof items.$ref === "string" ? items.$ref : undefined;
1110
+ if (ref) {
1111
+ const m = /^#\/(definitions|\$defs)\/(.+)$/.exec(ref);
1112
+ if (!m) {
1113
+ throw new Error(`resolveBlockItemSchema: unsupported $ref '${ref}' (only #/definitions/* or #/$defs/*)`);
1114
+ }
1115
+ const bag = (schema[m[1]] ?? {});
1116
+ const target = bag[m[2]];
1117
+ if (!target) {
1118
+ throw new Error(`resolveBlockItemSchema: $ref '${ref}' does not resolve in schema`);
1119
+ }
1120
+ items = target;
1121
+ }
1122
+ return { arrayKey, itemSchema: items };
1123
+ }
1124
+ /**
1125
+ * Allocate the next id for a block from its schema's id pattern — canonical and
1126
+ * `$ref`-aware (FGAP-084 / FGAP-083). Reads the block schema, resolves the item
1127
+ * subschema, parses the `id` pattern's prefix + minimum digit width
1128
+ * (`^FGAP-\d{3}$` → `FGAP-`/3; `^TASK-\d{3,}$` → `TASK-`/3), scans existing item
1129
+ * ids for the max numeric suffix, and returns `prefix` + zero-padded (maxN+1).
1130
+ * Throws when the schema is missing or the id pattern is absent / not
1131
+ * prefix+width parseable. Orchestrator CLIs + the in-pi append tool route
1132
+ * through this instead of re-implementing allocation.
1133
+ */
1134
+ export function nextId(cwd, blockName) {
1135
+ const schemaFile = blockSchemaPath(cwd, blockName);
1136
+ if (!fs.existsSync(schemaFile)) {
1137
+ throw new Error(`nextId: schema not found for block '${blockName}' at ${schemaFile}`);
1138
+ }
1139
+ const schema = JSON.parse(fs.readFileSync(schemaFile, "utf-8"));
1140
+ const { arrayKey, itemSchema } = resolveBlockItemSchema(schema);
1141
+ const idProp = (itemSchema.properties ?? {}).id;
1142
+ const pattern = idProp && typeof idProp.pattern === "string" ? idProp.pattern : undefined;
1143
+ if (!pattern) {
1144
+ throw new Error(`nextId: block '${blockName}' item schema has no id.pattern`);
1145
+ }
1146
+ const m = /^\^([A-Za-z_-]+)\\d\{(\d+)(?:,\d*)?\}\$$/.exec(pattern);
1147
+ if (!m) {
1148
+ throw new Error(`nextId: id pattern '${pattern}' for block '${blockName}' is not prefix+width parseable`);
1149
+ }
1150
+ const prefix = m[1];
1151
+ const width = Number.parseInt(m[2], 10);
1152
+ const data = readBlock(cwd, blockName);
1153
+ const items = (data[arrayKey] ?? []);
1154
+ const re = new RegExp(`^${prefix}(\\d+)$`);
1155
+ let maxN = 0;
1156
+ for (const it of items) {
1157
+ const id = typeof it.id === "string" ? it.id : "";
1158
+ const mm = re.exec(id);
1159
+ if (mm) {
1160
+ const n = Number.parseInt(mm[1], 10);
1161
+ if (n > maxN)
1162
+ maxN = n;
1163
+ }
1164
+ }
1165
+ return `${prefix}${String(maxN + 1).padStart(width, "0")}`;
1166
+ }
1167
+ //# sourceMappingURL=block-api.js.map