@open-agent-toolkit/cli 0.3.10 → 0.3.12

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 (163) hide show
  1. package/README.md +2 -1
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/cli-utilities/meta.json +13 -0
  4. package/assets/docs/cli-utilities/tool-packs.md +8 -2
  5. package/assets/docs/cli-utilities/workflow-gates.md +13 -0
  6. package/assets/docs/contributing/code.md +8 -2
  7. package/assets/docs/contributing/documentation.md +7 -1
  8. package/assets/docs/contributing/meta.json +15 -0
  9. package/assets/docs/docs-tooling/add-docs-to-a-repo.md +14 -5
  10. package/assets/docs/docs-tooling/commands.md +26 -8
  11. package/assets/docs/docs-tooling/meta.json +4 -0
  12. package/assets/docs/docs-tooling/workflows.md +2 -2
  13. package/assets/docs/guide/meta.json +4 -0
  14. package/assets/docs/meta.json +14 -0
  15. package/assets/docs/provider-sync/instruction-sync.md +8 -0
  16. package/assets/docs/provider-sync/meta.json +11 -0
  17. package/assets/docs/provider-sync/scope-and-surface.md +3 -4
  18. package/assets/docs/reference/cli-reference.md +5 -2
  19. package/assets/docs/reference/docs-index-contract.md +23 -6
  20. package/assets/docs/reference/file-locations.md +7 -2
  21. package/assets/docs/reference/index.md +1 -1
  22. package/assets/docs/reference/meta.json +10 -0
  23. package/assets/docs/reference/troubleshooting.md +41 -6
  24. package/assets/docs/workflows/ideas/meta.json +4 -0
  25. package/assets/docs/workflows/meta.json +4 -0
  26. package/assets/docs/workflows/projects/cursor-cloud.md +8 -3
  27. package/assets/docs/workflows/projects/evidence-layers.md +6 -8
  28. package/assets/docs/workflows/projects/implementation-execution.md +30 -4
  29. package/assets/docs/workflows/projects/lifecycle.md +7 -1
  30. package/assets/docs/workflows/projects/meta.json +25 -0
  31. package/assets/docs/workflows/projects/orchestration-model.md +5 -6
  32. package/assets/docs/workflows/skills/meta.json +10 -0
  33. package/assets/docs/workflows/skills/recon.md +31 -3
  34. package/assets/public-package-versions.json +4 -4
  35. package/assets/skills/oat-cursor-cloud-projects/SKILL.md +30 -16
  36. package/assets/skills/oat-dispatch-subagents/SKILL.md +4 -12
  37. package/assets/skills/oat-dispatch-subagents/references/managed-claude-example.json +1 -1
  38. package/assets/skills/oat-dispatch-subagents/references/provider-codex.md +51 -0
  39. package/assets/skills/oat-dispatch-subagents/references/record-schema.md +8 -7
  40. package/assets/skills/oat-docs-analyze/SKILL.md +28 -3
  41. package/assets/skills/oat-docs-apply/SKILL.md +3 -3
  42. package/assets/skills/oat-docs-authoring/SKILL.md +1 -1
  43. package/assets/skills/oat-docs-authoring/references/oat-fumadocs-contract.md +5 -2
  44. package/assets/skills/oat-docs-bootstrap/SKILL.md +4 -3
  45. package/assets/skills/oat-docs-bootstrap/assets/AGENTS.md.template +3 -3
  46. package/assets/skills/oat-pjm-decision/SKILL.md +3 -2
  47. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +4 -4
  48. package/assets/skills/oat-project-complete/SKILL.md +47 -2
  49. package/assets/skills/oat-project-design/SKILL.md +10 -6
  50. package/assets/skills/oat-project-discover/SKILL.md +3 -3
  51. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +3 -10
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +4 -4
  53. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  54. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +135 -3
  55. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +7 -9
  56. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +4 -4
  57. package/assets/skills/oat-project-implement/references/plan-and-resume.md +1 -1
  58. package/assets/skills/oat-project-import-plan/SKILL.md +3 -3
  59. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +4 -4
  60. package/assets/skills/oat-project-new/SKILL.md +3 -3
  61. package/assets/skills/oat-project-next/SKILL.md +50 -7
  62. package/assets/skills/oat-project-plan/SKILL.md +4 -4
  63. package/assets/skills/oat-project-plan-writing/SKILL.md +2 -2
  64. package/assets/skills/oat-project-pr-final/SKILL.md +11 -1
  65. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +4 -4
  66. package/assets/skills/oat-project-promote-spec-driven/SKILL.md +2 -2
  67. package/assets/skills/oat-project-quick-start/SKILL.md +12 -8
  68. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +4 -4
  69. package/assets/skills/oat-project-retro/SKILL.md +6 -3
  70. package/assets/skills/oat-project-review-provide/SKILL.md +2 -2
  71. package/assets/skills/oat-project-review-provide-remote/SKILL.md +2 -2
  72. package/assets/skills/oat-project-spec/SKILL.md +3 -3
  73. package/assets/skills/oat-project-summary/SKILL.md +22 -3
  74. package/assets/skills/oat-repo-improve/SKILL.md +2 -2
  75. package/assets/skills/recon/SKILL.md +24 -3
  76. package/assets/skills/recon/references/packet-contract.md +36 -6
  77. package/assets/skills/recon/references/profiles.md +3 -0
  78. package/assets/skills/recon/references/worker-contract.md +16 -3
  79. package/assets/skills/recon/scripts/create-review-brief.mjs +17 -86
  80. package/assets/skills/recon/scripts/lib/contracts.mjs +158 -41
  81. package/assets/skills/recon/scripts/lib/review-binding.mjs +123 -0
  82. package/assets/skills/recon/scripts/lib/routing.mjs +5 -2
  83. package/assets/skills/recon/scripts/reconcile-ledger.mjs +18 -7
  84. package/assets/skills/recon/scripts/render-packet.mjs +120 -1
  85. package/assets/skills/recon/scripts/validate-packet.mjs +119 -88
  86. package/assets/templates/state.md +1 -0
  87. package/dist/commands/backlog/new.d.ts +2 -2
  88. package/dist/commands/backlog/new.d.ts.map +1 -1
  89. package/dist/commands/backlog/new.js +2 -2
  90. package/dist/commands/decision/new.d.ts +2 -2
  91. package/dist/commands/decision/new.d.ts.map +1 -1
  92. package/dist/commands/decision/new.js +2 -2
  93. package/dist/commands/docs/nav/contents.d.ts +9 -0
  94. package/dist/commands/docs/nav/contents.d.ts.map +1 -1
  95. package/dist/commands/docs/nav/contents.js +7 -2
  96. package/dist/commands/docs/nav/fumadocs.d.ts +58 -0
  97. package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -0
  98. package/dist/commands/docs/nav/fumadocs.js +326 -0
  99. package/dist/commands/docs/nav/sync.d.ts +31 -1
  100. package/dist/commands/docs/nav/sync.d.ts.map +1 -1
  101. package/dist/commands/docs/nav/sync.js +163 -6
  102. package/dist/commands/index.d.ts.map +1 -1
  103. package/dist/commands/index.js +2 -0
  104. package/dist/commands/instructions/instructions.utils.d.ts +19 -0
  105. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  106. package/dist/commands/instructions/instructions.utils.js +89 -17
  107. package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
  108. package/dist/commands/instructions/sync/sync.js +49 -2
  109. package/dist/commands/pjm/init.d.ts.map +1 -1
  110. package/dist/commands/pjm/init.js +2 -2
  111. package/dist/commands/project/closeout-check/closeout-invariant.d.ts +97 -0
  112. package/dist/commands/project/closeout-check/closeout-invariant.d.ts.map +1 -0
  113. package/dist/commands/project/closeout-check/closeout-invariant.js +301 -0
  114. package/dist/commands/project/closeout-check/index.d.ts +38 -0
  115. package/dist/commands/project/closeout-check/index.d.ts.map +1 -0
  116. package/dist/commands/project/closeout-check/index.js +117 -0
  117. package/dist/commands/project/complete-state/index.d.ts +4 -0
  118. package/dist/commands/project/complete-state/index.d.ts.map +1 -1
  119. package/dist/commands/project/complete-state/index.js +11 -13
  120. package/dist/commands/project/dispatch/index.d.ts.map +1 -1
  121. package/dist/commands/project/dispatch/index.js +6 -30
  122. package/dist/commands/project/dispatch/record.d.ts +9 -18
  123. package/dist/commands/project/dispatch/record.d.ts.map +1 -1
  124. package/dist/commands/project/dispatch/record.js +15 -212
  125. package/dist/commands/project/index.d.ts.map +1 -1
  126. package/dist/commands/project/index.js +2 -0
  127. package/dist/commands/project/new/scaffold.d.ts +0 -1
  128. package/dist/commands/project/new/scaffold.d.ts.map +1 -1
  129. package/dist/commands/project/new/scaffold.js +19 -20
  130. package/dist/commands/project/promote/promote.d.ts +2 -2
  131. package/dist/commands/project/promote/promote.d.ts.map +1 -1
  132. package/dist/commands/project/promote/promote.js +13 -11
  133. package/dist/commands/shared/frontmatter.d.ts +21 -0
  134. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  135. package/dist/commands/shared/frontmatter.js +70 -4
  136. package/dist/commands/shared/template-source.d.ts +29 -0
  137. package/dist/commands/shared/template-source.d.ts.map +1 -0
  138. package/dist/commands/{pjm → shared}/template-source.js +24 -7
  139. package/dist/commands/state/generate.js +2 -2
  140. package/dist/commands/template/index.d.ts +10 -0
  141. package/dist/commands/template/index.d.ts.map +1 -0
  142. package/dist/commands/template/index.js +87 -0
  143. package/dist/commands/template/resolve.d.ts +31 -0
  144. package/dist/commands/template/resolve.d.ts.map +1 -0
  145. package/dist/commands/template/resolve.js +86 -0
  146. package/dist/fs/io.d.ts +0 -41
  147. package/dist/fs/io.d.ts.map +1 -1
  148. package/dist/fs/io.js +2 -414
  149. package/dist/providers/identity/absolute-paths.d.ts +4 -13
  150. package/dist/providers/identity/absolute-paths.d.ts.map +1 -1
  151. package/dist/providers/identity/absolute-paths.js +4 -17
  152. package/dist/providers/identity/generic-dispatch-record.d.ts +1 -1
  153. package/dist/providers/identity/generic-dispatch-record.js +2 -2
  154. package/dist/providers/identity/oat-dispatch-record.d.ts +4 -357
  155. package/dist/providers/identity/oat-dispatch-record.d.ts.map +1 -1
  156. package/dist/providers/identity/oat-dispatch-record.js +4 -236
  157. package/dist/release/public-package-contract.d.ts.map +1 -1
  158. package/dist/release/public-package-contract.js +12 -4
  159. package/dist/validation/skills.d.ts.map +1 -1
  160. package/dist/validation/skills.js +32 -6
  161. package/package.json +2 -2
  162. package/dist/commands/pjm/template-source.d.ts +0 -14
  163. package/dist/commands/pjm/template-source.d.ts.map +0 -1
package/dist/fs/io.js CHANGED
@@ -1,7 +1,5 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { chmod, lstat, link, mkdir, open, readdir, readFile, readlink, realpath, rename, rm, stat, symlink, writeFile, } from 'node:fs/promises';
3
- import { hostname } from 'node:os';
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
1
+ import { chmod, lstat, mkdir, readdir, readFile, readlink, rename, rm, stat, symlink, writeFile, } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
3
  export async function fileExists(path) {
6
4
  try {
7
5
  const s = await stat(path);
@@ -202,413 +200,3 @@ export async function atomicWriteJson(filePath, data) {
202
200
  await writeFile(tempPath, serializeJsonDocument(data), 'utf8');
203
201
  await rename(tempPath, filePath);
204
202
  }
205
- const WRITER_LOCK_RETRY_MS = 10;
206
- const WRITER_LOCK_TIMEOUT_MS = 5000;
207
- /** A lock younger than this is never reclaimed, so a writer that has not yet
208
- * written its holder file is not mistaken for a crash. */
209
- const WRITER_LOCK_MIN_RECLAIM_MS = 1000;
210
- /**
211
- * Hard cap. A lock older than this is reclaimed unconditionally — including one
212
- * whose holder is provably alive — because a dispatch write is a sub-second
213
- * operation and a holder past this bound is hung rather than working. This is
214
- * safe because the lock is not what protects the journal: the create-only
215
- * revision compare-and-swap is, and it holds independently of any lock, so a
216
- * wrongly reclaimed lock costs mutual exclusion but never correctness.
217
- */
218
- const WRITER_LOCK_HARD_STALE_MS = 15 * 60 * 1000;
219
- const HOLDER_FILE = 'holder.json';
220
- /**
221
- * Non-identifying, stable-per-host token. Liveness of a recorded pid is only
222
- * meaningful on the host that recorded it, and the raw hostname is never
223
- * written into a project artifact or an error message.
224
- */
225
- function hostId() {
226
- return createHash('sha256')
227
- .update(hostname(), 'utf8')
228
- .digest('hex')
229
- .slice(0, 16);
230
- }
231
- function processStartedAt() {
232
- return Math.round(Date.now() - process.uptime() * 1000);
233
- }
234
- function holderIsRunning(holder) {
235
- if (holder.hostId !== hostId()) {
236
- // Another host's pid says nothing about this one; fall back to age alone.
237
- return true;
238
- }
239
- try {
240
- process.kill(holder.pid, 0);
241
- return true;
242
- }
243
- catch (error) {
244
- // EPERM means the process exists but is owned by somebody else.
245
- return errorCode(error) !== 'ESRCH';
246
- }
247
- }
248
- async function readWriterLockHolder(lockPath) {
249
- try {
250
- const parsed = JSON.parse(await readFile(join(lockPath, HOLDER_FILE), 'utf8'));
251
- if (typeof parsed !== 'object' ||
252
- parsed === null ||
253
- typeof parsed.pid !== 'number' ||
254
- typeof parsed.hostId !== 'string') {
255
- return null;
256
- }
257
- return parsed;
258
- }
259
- catch {
260
- return null;
261
- }
262
- }
263
- /**
264
- * Bounded staleness policy. A lock is reclaimable when it is old enough that a
265
- * live writer would have finished, and either its recorded holder is provably
266
- * gone on this host or its age exceeds the hard cap. Returns the reason so the
267
- * caller can report what it did.
268
- */
269
- async function writerLockStaleReason(lockPath, minReclaimMs, hardStaleMs) {
270
- let age;
271
- try {
272
- age = Date.now() - (await lstat(lockPath)).mtimeMs;
273
- }
274
- catch {
275
- return null;
276
- }
277
- if (age < minReclaimMs)
278
- return null;
279
- const holder = await readWriterLockHolder(lockPath);
280
- if (holder !== null && !holderIsRunning(holder)) {
281
- return 'its recorded holder process is no longer running';
282
- }
283
- return age >= hardStaleMs ? 'it exceeded the staleness cap' : null;
284
- }
285
- /**
286
- * Exclusive contained writer lock. `mkdir` without `recursive` is the atomic
287
- * primitive: exactly one caller can create the directory, and every other
288
- * caller observes `EEXIST`. The lock is validated as a real directory so a
289
- * planted symlink fails closed instead of redirecting the guarded section, it
290
- * records holder evidence so a crashed writer can be reclaimed rather than
291
- * wedging the project forever, and no message it raises contains an absolute
292
- * path.
293
- */
294
- export async function withContainedWriterLock(lockPath, scopeRoot, run, options = {}) {
295
- const resolvedScope = resolve(scopeRoot);
296
- const resolvedLock = resolve(lockPath);
297
- if (dirname(resolvedLock) !== resolvedScope) {
298
- throw new Error('Writer lock must live directly inside its scope root.');
299
- }
300
- const scopeStat = await lstat(resolvedScope);
301
- if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
302
- throw new Error('Writer lock scope must be a real directory.');
303
- }
304
- const reportedLock = scopeRelative(resolvedScope, resolvedLock);
305
- const timeoutMs = options.timeoutMs ?? WRITER_LOCK_TIMEOUT_MS;
306
- const deadline = Date.now() + timeoutMs;
307
- let reclaimed = false;
308
- for (;;) {
309
- try {
310
- await mkdir(resolvedLock);
311
- break;
312
- }
313
- catch (error) {
314
- if (errorCode(error) !== 'EEXIST') {
315
- throw error;
316
- }
317
- if (!reclaimed) {
318
- const stale = await writerLockStaleReason(resolvedLock, options.minReclaimMs ?? WRITER_LOCK_MIN_RECLAIM_MS, options.hardStaleMs ?? WRITER_LOCK_HARD_STALE_MS);
319
- if (stale !== null) {
320
- const held = await lstat(resolvedLock);
321
- if (held.isSymbolicLink() || !held.isDirectory()) {
322
- throw new Error(`Writer lock ${reportedLock} is not a real directory.`, { cause: error });
323
- }
324
- await rm(resolvedLock, { recursive: true, force: true });
325
- reclaimed = true;
326
- continue;
327
- }
328
- }
329
- if (Date.now() >= deadline) {
330
- throw new Error(`Another writer holds ${reportedLock}. If no writer is running, remove ${reportedLock} inside the project and retry.`, { cause: error });
331
- }
332
- await new Promise((resolveDelay) => setTimeout(resolveDelay, WRITER_LOCK_RETRY_MS));
333
- }
334
- }
335
- const holder = {
336
- hostId: hostId(),
337
- pid: process.pid,
338
- processStartedAt: processStartedAt(),
339
- acquiredAt: new Date().toISOString(),
340
- };
341
- const held = await lstat(resolvedLock);
342
- if (held.isSymbolicLink() || !held.isDirectory()) {
343
- throw new Error(`Writer lock ${reportedLock} is not a real directory.`);
344
- }
345
- const lockIdentity = identityOf(held);
346
- try {
347
- await writeFile(join(resolvedLock, HOLDER_FILE), `${JSON.stringify(holder, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
348
- return await run();
349
- }
350
- finally {
351
- await releaseWriterLock(resolvedLock, lockIdentity, holder);
352
- }
353
- }
354
- /**
355
- * Release only a lock this call still provably holds. Both the directory
356
- * identity captured at acquisition and the holder record must still match. A
357
- * missing holder file counts as "not mine" — it is exactly the window in which
358
- * a reclaiming writer has re-created the directory but not yet written its own
359
- * record — so the replacement is left to the staleness policy rather than
360
- * removed from under its new owner.
361
- */
362
- async function releaseWriterLock(lockPath, identity, holder) {
363
- try {
364
- const current = await lstat(lockPath);
365
- if (current.isSymbolicLink() || !current.isDirectory())
366
- return;
367
- if (!sameIdentity(identityOf(current), identity))
368
- return;
369
- const currentHolder = await readWriterLockHolder(lockPath);
370
- if (currentHolder === null ||
371
- currentHolder.hostId !== holder.hostId ||
372
- currentHolder.pid !== holder.pid ||
373
- currentHolder.acquiredAt !== holder.acquiredAt) {
374
- return;
375
- }
376
- await rm(lockPath, { recursive: true, force: true });
377
- }
378
- catch {
379
- // Nothing releasable remains.
380
- }
381
- }
382
- function identityOf(entry) {
383
- return { device: String(entry.dev), inode: String(entry.ino) };
384
- }
385
- function sameIdentity(left, right) {
386
- return left.device === right.device && left.inode === right.inode;
387
- }
388
- async function assertUnchangedIdentity(path, expected, message) {
389
- if (!sameIdentity(identityOf(await lstat(path)), expected)) {
390
- throw new Error(message);
391
- }
392
- }
393
- /**
394
- * Scope-relative rendering for any path this module reports. NFR1 requires that
395
- * home paths and user-specific absolute paths stay out of durable output, so no
396
- * error raised below may carry an absolute path.
397
- */
398
- function scopeRelative(resolvedScope, target) {
399
- const rendered = relative(resolvedScope, resolve(target));
400
- return rendered === '' ? '.' : rendered.split(sep).join('/');
401
- }
402
- export function journalErrorCode(error) {
403
- return errorCode(error);
404
- }
405
- function errorCode(error) {
406
- return typeof error === 'object' && error !== null && 'code' in error
407
- ? String(error.code)
408
- : null;
409
- }
410
- /**
411
- * Re-raise a filesystem error without Node's absolute-path message text while
412
- * preserving its `code` so callers can still branch on `EEXIST`/`ENOENT`.
413
- * Exported so every journal producer redacts identically instead of each one
414
- * re-deriving the rule.
415
- */
416
- export function redactedFsError(error, operation, relativeTarget) {
417
- const code = errorCode(error);
418
- const raised = new Error(`Journal ${operation} failed for ${relativeTarget}${code === null ? '' : ` (${code})`}.`, { cause: error });
419
- return code === null ? raised : Object.assign(raised, { code });
420
- }
421
- /**
422
- * Node exposes no `openat`/`renameat`/`linkat`, so a path-based final operation
423
- * cannot be bound to a directory file descriptor. Publication is therefore
424
- * append-only and create-only: the destination must not already exist, the only
425
- * publishing syscall is `link`, and this module never issues `rename`, `rm`, or
426
- * `unlink` against a publication destination. A pathname swapped after the last
427
- * check can therefore cause at most an unreferenced create, never a replacement
428
- * or a deletion of content this call did not create.
429
- */
430
- async function publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope) {
431
- let published;
432
- try {
433
- published = await lstat(resolvedFile);
434
- }
435
- catch {
436
- return 'the published name disappeared';
437
- }
438
- if (published.isSymbolicLink() || !published.isFile()) {
439
- return 'the published name is not a regular file';
440
- }
441
- if (!sameIdentity(identityOf(published), tempIdentity)) {
442
- return 'the published name is not the staged file';
443
- }
444
- try {
445
- if (!sameIdentity(identityOf(await lstat(dirname(resolvedFile))), parentIdentity)) {
446
- return 'the parent directory identity changed';
447
- }
448
- const currentRealParent = await realpath(dirname(resolvedFile));
449
- const currentRelative = relative(realScope, currentRealParent);
450
- if (currentRelative === '..' ||
451
- currentRelative.startsWith(`..${sep}`) ||
452
- isAbsolute(currentRelative)) {
453
- return 'the parent directory resolves outside the allowed scope';
454
- }
455
- }
456
- catch {
457
- return 'the parent directory could not be re-validated';
458
- }
459
- return null;
460
- }
461
- /**
462
- * Remove the staging file only when the name still resolves to the exact inode
463
- * this call created. A swapped directory pathname therefore strands the staged
464
- * file rather than deleting an unrelated one. Returns whether the staged data
465
- * was provably removed, so callers can report the outcome accurately.
466
- *
467
- * Precisely: this removes only the *inode* this call created, not only the
468
- * *names* it created. A privileged process that hard-links the staged file into
469
- * a directory it controls and then swaps the journal pathname at it can have
470
- * that alias unlinked here. No data is destroyed — the inode stays linked from
471
- * the real directory, and no pre-existing foreign file can ever match, since a
472
- * distinct file has a distinct dev/ino pair.
473
- */
474
- async function removeStagedFile(tempPath, tempIdentity) {
475
- try {
476
- if (tempIdentity === null) {
477
- await rm(tempPath, { force: true });
478
- return 'removed';
479
- }
480
- const current = await lstat(tempPath);
481
- if (!sameIdentity(identityOf(current), tempIdentity)) {
482
- return 'stranded';
483
- }
484
- await rm(tempPath, { force: true });
485
- return 'removed';
486
- }
487
- catch {
488
- return 'stranded';
489
- }
490
- }
491
- /**
492
- * Publish one immutable JSON journal revision.
493
- *
494
- * Guarantees, stated as what the code does:
495
- * - The destination is created with `link` only. An occupied destination raises
496
- * `EEXIST`; the destination is never renamed onto, removed, or truncated.
497
- * - After publication the destination is proven to be the exact staged inode,
498
- * inside the same directory inode validated before staging, still resolving
499
- * inside `scopeRoot`. Failure raises and removes nothing.
500
- * - Staging cleanup removes only the inode this call created.
501
- * - No raised message contains an absolute path.
502
- *
503
- * Residual, unchanged by this design: a privileged concurrent process that
504
- * swaps the validated directory pathname can cause the `link` to create an
505
- * unreferenced file under a directory it controls, and can strand the staged
506
- * file. Neither outcome replaces or deletes pre-existing content.
507
- */
508
- export async function publishContainedJsonRevision(filePath, data, scopeRoot) {
509
- const resolvedScope = resolve(scopeRoot);
510
- const resolvedFile = resolve(filePath);
511
- const relativeFile = relative(resolvedScope, resolvedFile);
512
- if (relativeFile === '..' ||
513
- relativeFile.startsWith(`..${sep}`) ||
514
- isAbsolute(relativeFile)) {
515
- throw new Error('JSON journal destination is outside its allowed scope.');
516
- }
517
- const reportedFile = scopeRelative(resolvedScope, resolvedFile);
518
- const scopeStat = await lstat(resolvedScope);
519
- if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
520
- throw new Error('JSON journal scope must be a real directory.');
521
- }
522
- const realScope = await realpath(resolvedScope);
523
- const parentRelative = relative(resolvedScope, dirname(resolvedFile));
524
- const segments = parentRelative === '' ? [] : parentRelative.split(sep);
525
- let current = resolvedScope;
526
- for (const segment of segments) {
527
- current = join(current, segment);
528
- try {
529
- const currentStat = await lstat(current);
530
- if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) {
531
- throw new Error('JSON journal ancestry must not contain symlinks and must contain only real directories.');
532
- }
533
- }
534
- catch (error) {
535
- if (errorCode(error) !== 'ENOENT') {
536
- throw error;
537
- }
538
- await mkdir(current);
539
- }
540
- }
541
- const realParent = await realpath(dirname(resolvedFile));
542
- const relativeParent = relative(realScope, realParent);
543
- if (relativeParent === '..' ||
544
- relativeParent.startsWith(`..${sep}`) ||
545
- isAbsolute(relativeParent)) {
546
- throw new Error('JSON journal ancestry resolves outside its allowed scope.');
547
- }
548
- // Publication is create-only, so an already-published revision is a lost
549
- // compare-and-swap rather than something to overwrite.
550
- try {
551
- await lstat(resolvedFile);
552
- throw Object.assign(new Error(`Journal revision ${reportedFile} already exists.`), { code: 'EEXIST' });
553
- }
554
- catch (error) {
555
- if (errorCode(error) !== 'ENOENT') {
556
- throw error;
557
- }
558
- }
559
- const parentIdentity = identityOf(await lstat(dirname(resolvedFile)));
560
- const content = serializeJsonDocument(data);
561
- const tempPath = join(dirname(resolvedFile), `.${basename(resolvedFile)}.${randomUUID()}.tmp`);
562
- const reportedTemp = scopeRelative(resolvedScope, tempPath);
563
- let tempCreated = false;
564
- let stagedIdentity = null;
565
- try {
566
- let handle;
567
- try {
568
- handle = await open(tempPath, 'wx');
569
- }
570
- catch (error) {
571
- throw redactedFsError(error, 'staging', reportedTemp);
572
- }
573
- let tempIdentity;
574
- try {
575
- tempCreated = true;
576
- await handle.writeFile(content, 'utf8');
577
- tempIdentity = identityOf(await handle.stat());
578
- stagedIdentity = tempIdentity;
579
- }
580
- finally {
581
- await handle.close();
582
- }
583
- // Last ancestry check, then the identity binding that narrows the window
584
- // between that check and the path-based publication below.
585
- if ((await realpath(dirname(resolvedFile))) !== realParent) {
586
- throw new Error('JSON journal ancestry changed before publication.');
587
- }
588
- await assertUnchangedIdentity(dirname(resolvedFile), parentIdentity, 'JSON journal directory identity changed before publication.');
589
- await assertUnchangedIdentity(tempPath, tempIdentity, 'JSON journal staging file identity changed before publication.');
590
- try {
591
- await link(tempPath, resolvedFile);
592
- }
593
- catch (error) {
594
- throw redactedFsError(error, 'publication', reportedFile);
595
- }
596
- const misplaced = await publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope);
597
- if (misplaced) {
598
- // Never remove the destination: under a swapped pathname it may name
599
- // content this call did not create.
600
- const staged = await removeStagedFile(tempPath, tempIdentity);
601
- tempCreated = false;
602
- throw new Error(`Journal revision ${reportedFile} could not be verified inside the validated directory (${misplaced}). It was not published to the project journal, and this call removed and replaced nothing. ${staged === 'removed'
603
- ? 'Staged data was removed.'
604
- : 'A staged copy may remain outside the validated directory.'}`);
605
- }
606
- await removeStagedFile(tempPath, tempIdentity);
607
- tempCreated = false;
608
- }
609
- finally {
610
- if (tempCreated) {
611
- await removeStagedFile(tempPath, stagedIdentity);
612
- }
613
- }
614
- }
@@ -11,9 +11,10 @@
11
11
  *
12
12
  * Before this module there were two independent notions of
13
13
  * "looks like a path": a regex inside `redactDispatchMessage`, which only
14
- * scrubbed error text, and nothing at all on the persistence boundary. The
15
- * durable journal is committed to a shared repository, so a username or local
16
- * layout that reaches it is in git history permanently.
14
+ * scrubbed error text, and nothing at all on the record boundary. A validated
15
+ * record is printed verbatim and a caller may copy it into a committed
16
+ * artifact, so a username or local layout that reaches it can land in git
17
+ * history permanently.
17
18
  *
18
19
  * Message redaction and record sanitization now share this detector, because
19
20
  * the gap between them is precisely what allowed paths to be scrubbed from a
@@ -68,14 +69,4 @@ export declare function collectAbsolutePathViolations(value: unknown, path: stri
68
69
  * corrupt the identifier it claims to be.
69
70
  */
70
71
  export declare function assertNoAbsolutePath(value: unknown, path: string): void;
71
- /**
72
- * The publication postcondition, scoped to what is actually guaranteed.
73
- *
74
- * It hard-fails on a path in an identity or control field, which is the half of
75
- * NFR1 that is enforceable. It deliberately does **not** claim that no absolute
76
- * path remains anywhere in the revision: prose redaction is best-effort, and a
77
- * postcondition that asserted more than the sanitizer can deliver would be a
78
- * false guarantee rather than a check.
79
- */
80
- export declare function assertJournalIdentityHasNoAbsolutePath(identityFields: Readonly<Record<string, unknown>>): void;
81
72
  //# sourceMappingURL=absolute-paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"absolute-paths.d.ts","sourceRoot":"","sources":["../../../src/providers/identity/absolute-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AA6BH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAM/C;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE3D;AAYD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CActD;AAED,4EAA4E;AAC5E,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,MAAM,GACX,sBAAsB,EAAE,CAoB1B;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAGvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,sCAAsC,CACpD,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAChD,IAAI,CAIN"}
1
+ {"version":3,"file":"absolute-paths.d.ts","sourceRoot":"","sources":["../../../src/providers/identity/absolute-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AA6BH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAM/C;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE3D;AAYD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CActD;AAED,4EAA4E;AAC5E,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,MAAM,GACX,sBAAsB,EAAE,CAoB1B;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAGvE"}
@@ -11,9 +11,10 @@
11
11
  *
12
12
  * Before this module there were two independent notions of
13
13
  * "looks like a path": a regex inside `redactDispatchMessage`, which only
14
- * scrubbed error text, and nothing at all on the persistence boundary. The
15
- * durable journal is committed to a shared repository, so a username or local
16
- * layout that reaches it is in git history permanently.
14
+ * scrubbed error text, and nothing at all on the record boundary. A validated
15
+ * record is printed verbatim and a caller may copy it into a committed
16
+ * artifact, so a username or local layout that reaches it can land in git
17
+ * history permanently.
17
18
  *
18
19
  * Message redaction and record sanitization now share this detector, because
19
20
  * the gap between them is precisely what allowed paths to be scrubbed from a
@@ -141,17 +142,3 @@ export function assertNoAbsolutePath(value, path) {
141
142
  if (first)
142
143
  throw new Error(first.message);
143
144
  }
144
- /**
145
- * The publication postcondition, scoped to what is actually guaranteed.
146
- *
147
- * It hard-fails on a path in an identity or control field, which is the half of
148
- * NFR1 that is enforceable. It deliberately does **not** claim that no absolute
149
- * path remains anywhere in the revision: prose redaction is best-effort, and a
150
- * postcondition that asserted more than the sanitizer can deliver would be a
151
- * false guarantee rather than a check.
152
- */
153
- export function assertJournalIdentityHasNoAbsolutePath(identityFields) {
154
- for (const [field, value] of Object.entries(identityFields)) {
155
- assertNoAbsolutePath(value, `<journal>.${field}`);
156
- }
157
- }
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { type DispatchValueViolation } from './absolute-paths.js';
3
3
  /**
4
4
  * Whole-record ceiling, applied at every parse entry point so no combination
5
- * of individually legal fields can produce an unbounded journal revision.
5
+ * of individually legal fields can produce an unbounded record.
6
6
  */
7
7
  export declare function assertBoundedDispatchRecordSize(value: unknown): void;
8
8
  /**
@@ -34,7 +34,7 @@ const optionalSelector = identifier().nullable();
34
34
  /**
35
35
  * Every generic container field is validated as a closed, bounded JSON
36
36
  * projection so prompt bodies, transcripts, and other free-form content cannot
37
- * ride into a durable journal through an unbounded `unknown` value. `payload`
37
+ * ride into a validated record through an unbounded `unknown` value. `payload`
38
38
  * carries configured sandbox/tool controls; `candidates_considered`,
39
39
  * `configured_invocation_evidence`, `continuation_events`, `diagnostics`, and
40
40
  * `escalate_when` carry short references and identifiers, not narrative text.
@@ -134,7 +134,7 @@ function projectionAggregateViolation(value, path) {
134
134
  }
135
135
  /**
136
136
  * Whole-record ceiling, applied at every parse entry point so no combination
137
- * of individually legal fields can produce an unbounded journal revision.
137
+ * of individually legal fields can produce an unbounded record.
138
138
  */
139
139
  export function assertBoundedDispatchRecordSize(value) {
140
140
  const bytes = publishedByteLength(value);