atris 3.57.4 → 3.58.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +12 -1
  2. package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
  3. package/atris/skills/create-member/SKILL.md +16 -13
  4. package/atris/skills/design/SKILL.md +27 -4
  5. package/atris/skills/engines/SKILL.md +55 -12
  6. package/atris/skills/x-search/SKILL.md +8 -6
  7. package/atris/skills/youtube/SKILL.md +37 -8
  8. package/atris/team/brainstormer/MEMBER.md +2 -2
  9. package/atris/team/executor/MEMBER.md +3 -3
  10. package/atris/team/launcher/MEMBER.md +4 -4
  11. package/atris/team/navigator/MEMBER.md +3 -3
  12. package/atris/team/researcher/MEMBER.md +2 -2
  13. package/atris/team/validator/MEMBER.md +1 -1
  14. package/atris.md +14 -0
  15. package/ax +153 -23
  16. package/bin/atris.js +78 -30
  17. package/commands/aeo.js +4 -4
  18. package/commands/agents.js +1 -1
  19. package/commands/align.js +12 -12
  20. package/commands/analytics.js +3 -3
  21. package/commands/app.js +5 -5
  22. package/commands/ask.js +175 -0
  23. package/commands/auth.js +7 -7
  24. package/commands/autoland.js +29 -5
  25. package/commands/autopilot-front.js +6 -6
  26. package/commands/autopilot.js +90 -90
  27. package/commands/avail.js +5 -5
  28. package/commands/bench.js +63 -1
  29. package/commands/browse.js +2 -2
  30. package/commands/business.js +12 -12
  31. package/commands/card.js +2 -2
  32. package/commands/clean.js +13 -13
  33. package/commands/close.js +3 -3
  34. package/commands/compile.js +13 -13
  35. package/commands/computer.js +94 -27
  36. package/commands/console.js +5 -5
  37. package/commands/decide.js +1 -1
  38. package/commands/deck.js +9 -9
  39. package/commands/drive.js +4 -4
  40. package/commands/engine.js +173 -19
  41. package/commands/errors.js +3 -3
  42. package/commands/experiments.js +18 -1
  43. package/commands/feed.js +2 -2
  44. package/commands/feedback.js +4 -4
  45. package/commands/fleet.js +12 -12
  46. package/commands/game.js +1 -1
  47. package/commands/guide.js +49 -0
  48. package/commands/improve.js +19 -17
  49. package/commands/init.js +67 -19
  50. package/commands/interview.js +7 -7
  51. package/commands/land.js +40 -15
  52. package/commands/learn.js +12 -12
  53. package/commands/lesson.js +2 -2
  54. package/commands/log.js +1 -1
  55. package/commands/loops.js +4 -4
  56. package/commands/member.js +75 -50
  57. package/commands/mission.js +90 -82
  58. package/commands/one-lap.js +1 -1
  59. package/commands/pack.js +74 -8
  60. package/commands/plugin.js +6 -6
  61. package/commands/probe.js +9 -9
  62. package/commands/pull.js +35 -35
  63. package/commands/pulse.js +1 -1
  64. package/commands/push.js +21 -21
  65. package/commands/rainmaker.js +1 -1
  66. package/commands/recap.js +7 -7
  67. package/commands/reel.js +2 -2
  68. package/commands/release.js +3 -3
  69. package/commands/report.js +1 -1
  70. package/commands/review.js +5 -5
  71. package/commands/revisions.js +1 -1
  72. package/commands/run.js +1 -1
  73. package/commands/scout.js +3 -3
  74. package/commands/security-review.js +7 -7
  75. package/commands/serve.js +9 -9
  76. package/commands/setup.js +1 -1
  77. package/commands/sign.js +4 -4
  78. package/commands/signup.js +4 -4
  79. package/commands/site.js +8 -3
  80. package/commands/skill.js +6 -6
  81. package/commands/slop.js +18 -18
  82. package/commands/social.js +3 -3
  83. package/commands/soul.js +14 -14
  84. package/commands/spaceship.js +1 -1
  85. package/commands/status.js +2 -2
  86. package/commands/strings.js +13 -13
  87. package/commands/study.js +4 -4
  88. package/commands/sync.js +8 -8
  89. package/commands/task.js +50 -25
  90. package/commands/teach.js +2 -2
  91. package/commands/team.js +1 -1
  92. package/commands/theme.js +2 -2
  93. package/commands/tree.js +34 -0
  94. package/commands/truth.js +7 -7
  95. package/commands/verify.js +12 -12
  96. package/commands/visualize.js +2 -2
  97. package/commands/workflow.js +97 -40
  98. package/commands/worktree.js +1 -1
  99. package/commands/write.js +9 -9
  100. package/commands/x-search.js +89 -17
  101. package/commands/xp.js +6 -3
  102. package/commands/youtube.js +601 -48
  103. package/lib/accept-verify-gate.js +3 -3
  104. package/lib/activity-stream.js +1 -1
  105. package/lib/auto-accept-certified.js +28 -12
  106. package/lib/autoland.js +6 -6
  107. package/lib/bench/engines.js +59 -8
  108. package/lib/bench/report.js +1 -1
  109. package/lib/bench/runner.js +204 -5
  110. package/lib/bench/tree-render.js +76 -0
  111. package/lib/card.js +1 -1
  112. package/lib/chat-log-scan.js +1 -1
  113. package/lib/claude-boot-block.js +2 -0
  114. package/lib/deck-compose.js +1 -1
  115. package/lib/deck-history.js +2 -2
  116. package/lib/deck-layout.js +1 -1
  117. package/lib/deck-review.js +5 -5
  118. package/lib/deck-schema.js +1 -1
  119. package/lib/default-verifier.js +1 -1
  120. package/lib/developer-api.js +3 -0
  121. package/lib/engine-ask.js +45 -7
  122. package/lib/engine-registry.js +15 -2
  123. package/lib/file-ops.js +1 -1
  124. package/lib/fleet.js +71 -35
  125. package/lib/html-render.js +1 -1
  126. package/lib/intents.js +172 -0
  127. package/lib/journal.js +1 -1
  128. package/lib/known-commands.js +2 -2
  129. package/lib/land-green.js +364 -0
  130. package/lib/lesson-contradiction.js +2 -2
  131. package/lib/lesson-preflight.js +1 -1
  132. package/lib/manifest.js +5 -5
  133. package/lib/member-alive.js +9 -5
  134. package/lib/memory-view.js +3 -3
  135. package/lib/mission-protected-lane.js +1 -1
  136. package/lib/mission-root.js +2 -2
  137. package/lib/orb-context.js +10 -10
  138. package/lib/pack-capabilities.js +861 -3
  139. package/lib/permission-grants.js +2 -2
  140. package/lib/policy-lessons.js +6 -6
  141. package/lib/pulse.js +8 -8
  142. package/lib/receipt-evidence.js +1 -1
  143. package/lib/reel.js +1 -1
  144. package/lib/review-integrity.js +1 -1
  145. package/lib/runner-command.js +35 -5
  146. package/lib/scorecard.js +4 -4
  147. package/lib/security-scan.js +5 -5
  148. package/lib/self-drive.js +1 -1
  149. package/lib/site-publish.js +372 -0
  150. package/lib/site.js +1 -1
  151. package/lib/slides-deck.js +2 -2
  152. package/lib/state-detection.js +2 -2
  153. package/lib/sync-telemetry.js +2 -2
  154. package/lib/task-db.js +17 -10
  155. package/lib/task-explanation.js +1 -1
  156. package/lib/task-proof.js +2 -2
  157. package/lib/task-receipt.js +3 -3
  158. package/lib/theme.js +12 -12
  159. package/lib/todo-fallback.js +3 -3
  160. package/lib/todo-sections.js +1 -1
  161. package/lib/tree-hash.js +79 -0
  162. package/lib/usage.js +1 -1
  163. package/lib/wish-delegate.js +1 -1
  164. package/lib/workspace-safety.js +4 -4
  165. package/lib/workspace-scaffold.js +3 -3
  166. package/package.json +1 -1
  167. package/scripts/det/concierge-bakeoff.sh +26 -0
  168. package/scripts/det/ytquote-repair.js +0 -1
  169. package/scripts/det/ytrail-race.js +0 -5
  170. package/scripts/member-operate.mjs +52 -14
  171. package/scripts/outbound-artifact-gate.js +0 -3
  172. package/utils/auth.js +0 -3
  173. package/utils/config.js +0 -1
  174. package/scripts/det/checklist-score.js +0 -191
@@ -5,7 +5,8 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const dns = require('dns').promises;
7
7
  const net = require('net');
8
- const { randomUUID } = require('crypto');
8
+ const { randomUUID, createHash } = require('crypto');
9
+ const { isDeepStrictEqual } = require('util');
9
10
  const { enforceConfigGuard } = require('./config-guard');
10
11
 
11
12
  const CAPABILITY_DEFINITIONS = Object.freeze({
@@ -35,6 +36,12 @@ const TOOL_ORDER = Object.freeze([
35
36
  ]);
36
37
 
37
38
  const FILE_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Edit', 'Write']);
39
+ // File tools that mutate bytes. Only these are journaled with an action
40
+ // identity and only these are ever denied as recovered-completed paths.
41
+ const FILE_MUTATION_TOOLS = new Set(['Edit', 'Write']);
42
+ // Journal support marker. Runs launched before this marker carry no per-action
43
+ // identity, so their receipts can never back a recovery.
44
+ const PACK_JOURNAL_VERSION = 1;
38
45
  const PRE_TOOL_USE_MATCHER = 'Read|Glob|Grep|Edit|Write|WebFetch|Bash';
39
46
  const CLAUDE_SESSION_END_REASONS = new Set([
40
47
  'clear',
@@ -271,6 +278,12 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
271
278
  ));
272
279
  const exit = [...events].reverse().find((event) => event.event === 'exit');
273
280
  const sessionEnd = [...events].reverse().find((event) => event.event === 'session-end');
281
+ const fileEffects = pairPackFileEffects(events);
282
+ const inheritedProtected = requireInheritedProtection(launch, null);
283
+ const protectedFiles = mergeProtectedFiles(inheritedProtected, fileEffects.confirmed);
284
+ const recoveryMode = launch.recovery && launch.recovery.mode === 'recovered' ? 'recovered' : 'fresh';
285
+ const journalSupported = Boolean(launch.journal && launch.journal.supported === true
286
+ && launch.journal.version === PACK_JOURNAL_VERSION);
274
287
  const summary = {
275
288
  schema: launch.schema,
276
289
  runId: launch.runId,
@@ -292,12 +305,32 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
292
305
  usedTools,
293
306
  deniedUses: events.filter((event) => event.event === 'denied').map(({ at, tool, reason }) => ({ at, tool, reason })),
294
307
  failedTools: events.filter((event) => event.event === 'failed').map(({ at, tool }) => ({ at, tool })),
308
+ journal: launch.journal && typeof launch.journal === 'object'
309
+ ? launch.journal
310
+ : { version: 0, supported: false },
311
+ fileEffects: {
312
+ confirmed: fileEffects.confirmed,
313
+ unresolved: fileEffects.unresolved,
314
+ failed: fileEffects.failed,
315
+ },
316
+ recovery: {
317
+ mode: recoveryMode,
318
+ ...(recoveryMode === 'recovered'
319
+ ? { parentRunId: launch.recovery.parentRunId || null, parentReceipt: launch.recovery.parentReceipt || null }
320
+ : {}),
321
+ protectedFiles,
322
+ journalVersion: (launch.journal && launch.journal.version) || 0,
323
+ hasMissingIdentity: fileEffects.unresolved.some((item) => item.reason === 'missing-identity'),
324
+ },
295
325
  observability: {
296
326
  denialCoverage: 'atris-hooks-only',
297
327
  runtimePermissionDenialsCaptured: false,
298
328
  toolInputsLogged: false,
299
329
  directSkillInvocationsCaptured: false,
300
330
  runnerExitCaptured: Boolean(exit),
331
+ fileEffectIdentitiesLogged: journalSupported,
332
+ recoveryLinked: recoveryMode === 'recovered',
333
+ protectedFilesCarried: protectedFiles.length,
301
334
  },
302
335
  enforcement: launch.enforcement,
303
336
  events: path.basename(eventsPath),
@@ -309,6 +342,796 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
309
342
  return summary;
310
343
  }
311
344
 
345
+ // ── file-effect journal and explicit recovery ─────────────────────────────
346
+ // Intent is journaled in PreToolUse after every guard approves but before the
347
+ // tool executes, keyed by tool_use_id plus canonical relative target. No raw
348
+ // content or arguments ever enter the journal. PostToolUse confirms the same
349
+ // identity with the on-disk SHA-256. A missing post stays unresolved, and an
350
+ // interactive permission denial (prompt answered "no") can also leave an
351
+ // intent with no post hook, so recovery primarily serves headless/trusted
352
+ // runs and refuses anything unresolved rather than risk replay.
353
+ function hookToolUseId(input) {
354
+ const id = input && input.tool_use_id;
355
+ return typeof id === 'string' && id.trim() ? id : null;
356
+ }
357
+
358
+ function sha256Hex(data) {
359
+ return createHash('sha256').update(data).digest('hex');
360
+ }
361
+
362
+ function journalRelativeTarget(tool, toolInput, root) {
363
+ const targetValue = fileToolTarget(tool, toolInput || {}, root);
364
+ if (!targetValue) return null;
365
+ const realRoot = fs.realpathSync(root);
366
+ const rel = path.relative(realRoot, path.resolve(realRoot, targetValue));
367
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
368
+ return rel.split(path.sep).join('/');
369
+ }
370
+
371
+ function readFileIdentity(absPath) {
372
+ try {
373
+ const stat = fs.statSync(absPath);
374
+ if (!stat.isFile()) return { present: true, isFile: false };
375
+ return {
376
+ present: true,
377
+ isFile: true,
378
+ sha256: sha256Hex(fs.readFileSync(absPath)),
379
+ dev: stat.dev,
380
+ ino: stat.ino,
381
+ nlink: stat.nlink,
382
+ };
383
+ } catch (error) {
384
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { present: false };
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ function readProtectedFiles() {
390
+ const raw = process.env.ATRIS_PACK_PROTECTED_FILES;
391
+ if (raw === undefined || raw === null || raw === '') return { ok: true, files: [] };
392
+ try {
393
+ const parsed = JSON.parse(raw);
394
+ if (!Array.isArray(parsed)) return { ok: false, files: [] };
395
+ const files = [];
396
+ for (const entry of parsed) {
397
+ if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string') {
398
+ return { ok: false, files: [] };
399
+ }
400
+ files.push(entry);
401
+ }
402
+ return { ok: true, files };
403
+ } catch {
404
+ return { ok: false, files: [] };
405
+ }
406
+ }
407
+
408
+ function readAuthoritativeProtectedFiles(env) {
409
+ const raw = fs.readFileSync(env.eventsPath, 'utf8');
410
+ let launch = null;
411
+ for (const line of raw.split('\n')) {
412
+ if (!line.trim()) continue;
413
+ const parsed = JSON.parse(line);
414
+ if (parsed && parsed.event === 'launch') {
415
+ if (launch) throw new Error('recovery journal has duplicate launch events');
416
+ launch = parsed;
417
+ }
418
+ }
419
+ if (!launch) throw new Error('recovery journal is missing its launch event');
420
+ const recovery = launch.recovery || { mode: 'fresh' };
421
+ if (recovery.mode !== undefined && recovery.mode !== 'fresh' && recovery.mode !== 'recovered') {
422
+ throw new Error('recovery journal has malformed recovered protection');
423
+ }
424
+ if (recovery.mode === 'recovered') {
425
+ if (typeof recovery.parentRunId !== 'string' || !recovery.parentRunId
426
+ || typeof recovery.parentReceipt !== 'string' || !recovery.parentReceipt
427
+ || !Array.isArray(recovery.protectedFiles)) {
428
+ throw new Error('recovery journal has malformed recovered protection');
429
+ }
430
+ for (const entry of recovery.protectedFiles) {
431
+ if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string'
432
+ || !/^[0-9a-f]{64}$/.test(entry.sha256)) {
433
+ throw new Error('recovery journal has malformed recovered protection');
434
+ }
435
+ }
436
+ return { mode: 'recovered', files: recovery.protectedFiles };
437
+ }
438
+ if (!Array.isArray(recovery.protectedFiles || [])) throw new Error('recovery journal has malformed protection');
439
+ return { mode: 'fresh', files: Array.isArray(recovery.protectedFiles) ? recovery.protectedFiles : [] };
440
+ }
441
+
442
+ function protectedFileMatch(rel, absIdentity, protectedFiles) {
443
+ for (const entry of protectedFiles) {
444
+ if (entry.target === rel) return entry;
445
+ // Device/inode pairing catches case aliases on macOS, where a differently
446
+ // cased path resolves to the same bytes through no new directory entry.
447
+ if (absIdentity && absIdentity.present && absIdentity.isFile !== false
448
+ && Number.isFinite(entry.dev) && Number.isFinite(entry.ino)
449
+ && absIdentity.dev === entry.dev && absIdentity.ino === entry.ino) {
450
+ return entry;
451
+ }
452
+ }
453
+ return null;
454
+ }
455
+
456
+ // Runs after the file guards approve. Denies recovered-completed paths by
457
+ // path AND device/inode; anything else passes through to journaling.
458
+ // Fails closed: malformed protection, unreadable journal, or identity errors
459
+ // deny rather than becoming an empty set.
460
+ function isAuthoritativeJournalPath(env, lexical) {
461
+ for (const candidate of [env.receiptPath, env.eventsPath]) {
462
+ if (!candidate) continue;
463
+ try {
464
+ const realCandidate = fs.realpathSync(candidate);
465
+ let realLexical = null;
466
+ try {
467
+ realLexical = realBoundaryPath(lexical);
468
+ } catch {
469
+ realLexical = null;
470
+ }
471
+ if (realLexical && realLexical === realCandidate) return true;
472
+ try {
473
+ const a = fs.statSync(lexical);
474
+ const b = fs.statSync(candidate);
475
+ if (a.dev === b.dev && a.ino === b.ino) return true;
476
+ } catch {
477
+ // Fall through to lexical comparison below; stat failures stay denied
478
+ // only if the paths already matched, never as an allow.
479
+ }
480
+ if (path.resolve(candidate) === lexical) return true;
481
+ } catch {
482
+ if (path.resolve(candidate) === lexical) return true;
483
+ }
484
+ }
485
+ return false;
486
+ }
487
+
488
+ function checkProtectedMutation(env, input) {
489
+ const tool = input && input.tool_name;
490
+ if (!FILE_MUTATION_TOOLS.has(tool)) return null;
491
+ const toolInput = input.tool_input || {};
492
+ const targetValue = fileToolTarget(tool, toolInput, env.root);
493
+ if (!targetValue) return null;
494
+ const realRoot = fs.realpathSync(env.root);
495
+ const lexical = path.resolve(realRoot, targetValue);
496
+ const rel = path.relative(realRoot, lexical).split(path.sep).join('/');
497
+ if (isAuthoritativeJournalPath(env, lexical)) {
498
+ return { denied: true, reason: `${tool} to the pack run journal is denied: the journal is outside model reach` };
499
+ }
500
+ let identity = null;
501
+ try {
502
+ const stat = fs.statSync(lexical);
503
+ identity = stat.isFile()
504
+ ? { present: true, dev: stat.dev, ino: stat.ino }
505
+ : { present: true };
506
+ } catch (error) {
507
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
508
+ identity = { present: false };
509
+ } else {
510
+ return { denied: true, reason: `${tool} identity check failed; refusing the mutation rather than risk replay` };
511
+ }
512
+ }
513
+ let authoritative = null;
514
+ try {
515
+ authoritative = readAuthoritativeProtectedFiles(env);
516
+ } catch {
517
+ return { denied: true, reason: `${tool} protection state is unreadable; refusing the mutation rather than risk replay` };
518
+ }
519
+ const envBlob = readProtectedFiles();
520
+ if (!envBlob.ok) {
521
+ return { denied: true, reason: `${tool} protection state is malformed; refusing the mutation rather than risk replay` };
522
+ }
523
+ const combined = [...authoritative.files];
524
+ for (const entry of envBlob.files) {
525
+ if (!combined.some((item) => item.target === entry.target && item.sha256 === entry.sha256)) {
526
+ combined.push(entry);
527
+ }
528
+ }
529
+ if (authoritative.mode === 'recovered') {
530
+ const envTargets = new Set(envBlob.files.map((entry) => entry.target));
531
+ for (const entry of authoritative.files) {
532
+ if (!envTargets.has(entry.target)) {
533
+ return { denied: true, reason: `${tool} protection state is incomplete; refusing the mutation rather than risk replay` };
534
+ }
535
+ }
536
+ }
537
+ const hit = protectedFileMatch(rel, identity, combined);
538
+ if (hit) {
539
+ return { denied: true, reason: `${tool} to ${hit.target} is denied: the file completed in a prior run and is protected during recovery` };
540
+ }
541
+ return null;
542
+ }
543
+
544
+ function journalFileIntent(env, input) {
545
+ const tool = input && input.tool_name;
546
+ if (!FILE_MUTATION_TOOLS.has(tool)) return;
547
+ appendReceiptEvent(env.eventsPath, {
548
+ event: 'intent',
549
+ at: new Date().toISOString(),
550
+ tool,
551
+ tool_use_id: hookToolUseId(input),
552
+ target: journalRelativeTarget(tool, input.tool_input || {}, env.root),
553
+ });
554
+ // A journal write failure throws, so pre-hook setup exits 2 and the tool
555
+ // never runs unrecorded.
556
+ finalizePackRunReceipt(env.receiptPath, env.eventsPath);
557
+ }
558
+
559
+ function sanitizeLaunchRecovery(recovery) {
560
+ if (!recovery || typeof recovery !== 'object') return { mode: 'fresh' };
561
+ const protectedFiles = Array.isArray(recovery.protectedFiles)
562
+ ? recovery.protectedFiles
563
+ .filter((entry) => entry && typeof entry.target === 'string' && typeof entry.sha256 === 'string')
564
+ .map((entry) => ({ target: entry.target, sha256: entry.sha256, dev: entry.dev, ino: entry.ino }))
565
+ : [];
566
+ return {
567
+ mode: 'recovered',
568
+ parentRunId: typeof recovery.parentRunId === 'string' ? recovery.parentRunId : null,
569
+ parentReceipt: typeof recovery.parentReceipt === 'string' ? recovery.parentReceipt : null,
570
+ protectedFiles,
571
+ };
572
+ }
573
+
574
+ function mergeProtectedFiles(inherited, confirmed) {
575
+ const merged = [...inherited];
576
+ for (const entry of confirmed) {
577
+ const record = { target: entry.target, sha256: entry.sha256, dev: entry.dev, ino: entry.ino };
578
+ const index = merged.findIndex((item) => item.target === record.target);
579
+ if (index === -1) merged.push(record);
580
+ else merged[index] = record;
581
+ }
582
+ return merged;
583
+ }
584
+
585
+ // Pairs intent events with their post events by tool_use_id. Anything that
586
+ // does not pair cleanly is unresolved, including confirmations that arrive
587
+ // without an intent and intents whose confirmation never arrived. Strict:
588
+ // each mutation has exactly one identity, intent precedes post with same
589
+ // tool AND target, duplicates/unknown shapes/malformed hashes refuse.
590
+ function pairPackFileEffects(events) {
591
+ const confirmed = [];
592
+ const unresolved = [];
593
+ const failed = [];
594
+ const KNOWN_TOOLS = new Set([...TOOL_ORDER, ...FILE_MUTATION_TOOLS]);
595
+ const intentIndices = new Map();
596
+ const postIndices = new Map();
597
+ events.forEach((event, index) => {
598
+ if (!event || typeof event.event !== 'string') {
599
+ unresolved.push({ tool: null, target: null, tool_use_id: null, reason: 'malformed-event' });
600
+ return;
601
+ }
602
+ if (event.event === 'intent') {
603
+ if (!FILE_MUTATION_TOOLS.has(event.tool)) {
604
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-tool' });
605
+ return;
606
+ }
607
+ const id = typeof event.tool_use_id === 'string' && event.tool_use_id ? event.tool_use_id : null;
608
+ if (!id || typeof event.target !== 'string' || !event.target) {
609
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'missing-identity' });
610
+ return;
611
+ }
612
+ if (intentIndices.has(id)) {
613
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'duplicate-intent' });
614
+ return;
615
+ }
616
+ intentIndices.set(id, index);
617
+ } else if (event.event === 'used' || event.event === 'failed') {
618
+ if (!event.tool || !KNOWN_TOOLS.has(event.tool)) {
619
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-tool' });
620
+ return;
621
+ }
622
+ if (!FILE_MUTATION_TOOLS.has(event.tool)) return;
623
+ const id = typeof event.tool_use_id === 'string' && event.tool_use_id ? event.tool_use_id : null;
624
+ if (!id) {
625
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: null, reason: 'missing-identity' });
626
+ return;
627
+ }
628
+ if (postIndices.has(id)) {
629
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'duplicate-post' });
630
+ return;
631
+ }
632
+ postIndices.set(id, index);
633
+ } else if (!['launch', 'exit', 'denied', 'session-end'].includes(event.event)) {
634
+ unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-event' });
635
+ }
636
+ });
637
+ const intents = [];
638
+ const postsById = new Map();
639
+ events.forEach((event) => {
640
+ if (event.event === 'intent' && FILE_MUTATION_TOOLS.has(event.tool)
641
+ && typeof event.tool_use_id === 'string' && event.tool_use_id
642
+ && typeof event.target === 'string' && event.target
643
+ && !intents.some((item) => item.tool_use_id === event.tool_use_id)) {
644
+ intents.push(event);
645
+ }
646
+ if ((event.event === 'used' || event.event === 'failed') && FILE_MUTATION_TOOLS.has(event.tool)
647
+ && typeof event.tool_use_id === 'string' && event.tool_use_id
648
+ && !Array.from(postsById.values()).flat().includes(event)) {
649
+ if (!postsById.has(event.tool_use_id)) postsById.set(event.tool_use_id, []);
650
+ postsById.get(event.tool_use_id).push(event);
651
+ }
652
+ });
653
+ const consumed = new Set();
654
+ for (const intent of intents) {
655
+ const id = intent.tool_use_id;
656
+ const post = ((postsById.get(id) || []).filter((candidate) => !consumed.has(candidate)))[0] || null;
657
+ if (!post) {
658
+ if (!unresolved.some((item) => item.tool_use_id === id)) {
659
+ unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'missing-post' });
660
+ }
661
+ continue;
662
+ }
663
+ consumed.add(post);
664
+ const intentIndex = events.indexOf(intent);
665
+ const postIndex = events.indexOf(post);
666
+ if (postIndex < intentIndex) {
667
+ unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'out-of-order' });
668
+ continue;
669
+ }
670
+ if (post.tool !== intent.tool || post.target !== intent.target) {
671
+ unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'confirmation-mismatch' });
672
+ continue;
673
+ }
674
+ if (post.event === 'failed') {
675
+ failed.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id });
676
+ continue;
677
+ }
678
+ if (typeof post.fileSha256 === 'string' && /^[0-9a-f]{64}$/.test(post.fileSha256)) {
679
+ confirmed.push({
680
+ tool: intent.tool || null, target: intent.target || null,
681
+ sha256: post.fileSha256, dev: post.dev, ino: post.ino, tool_use_id: id,
682
+ });
683
+ } else {
684
+ unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'confirmation-mismatch' });
685
+ }
686
+ }
687
+ for (const [id, list] of postsById.entries()) {
688
+ for (const post of list) {
689
+ if (consumed.has(post)) continue;
690
+ unresolved.push({ tool: post.tool || null, target: post.target || null, tool_use_id: id, reason: 'missing-intent' });
691
+ }
692
+ }
693
+ return { confirmed, unresolved, failed };
694
+ }
695
+
696
+ function readStrictPackEvents(eventsPath) {
697
+ const raw = fs.readFileSync(eventsPath, 'utf8');
698
+ const events = [];
699
+ for (const line of raw.split('\n')) {
700
+ if (!line.trim()) continue;
701
+ let parsed = null;
702
+ try {
703
+ parsed = JSON.parse(line);
704
+ } catch {
705
+ throw new Error('recovery journal is corrupt (unparseable event); refusing recovery rather than risk replay');
706
+ }
707
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.event !== 'string') {
708
+ throw new Error('recovery journal is corrupt (malformed event); refusing recovery rather than risk replay');
709
+ }
710
+ events.push(parsed);
711
+ }
712
+ return events;
713
+ }
714
+
715
+ function assertJournalTarget(target, root) {
716
+ if (typeof target !== 'string' || !target || target.startsWith('/') || target.split('/').includes('..')) {
717
+ throw new Error('recovery journal is corrupt (unsafe file target); refusing recovery rather than risk replay');
718
+ }
719
+ if (!insideRoot(path.resolve(root, target), root)) {
720
+ throw new Error('recovery journal records a path outside the pack root; refusing recovery');
721
+ }
722
+ return path.resolve(root, target);
723
+ }
724
+
725
+ function realPathOutsideCheck(candidatePath, rootReal) {
726
+ let real = null;
727
+ try {
728
+ real = fs.realpathSync(candidatePath);
729
+ } catch {
730
+ try {
731
+ real = realBoundaryPath(path.resolve(candidatePath));
732
+ } catch {
733
+ throw new Error('recovery path could not be resolved safely outside the pack root; refusing recovery');
734
+ }
735
+ }
736
+ if (insideRoot(real, rootReal) || real === rootReal) {
737
+ throw new Error('recovery receipt, journal, claim, or child receipt directory must live outside the pack root; refusing recovery');
738
+ }
739
+ return real;
740
+ }
741
+
742
+ function assertRecoveryPlacementOutsideRoot({ rootReal, parentReceiptPath, eventsPath, claimPath, childReceiptDir }) {
743
+ for (const candidate of [parentReceiptPath, eventsPath, claimPath].filter(Boolean)) {
744
+ realPathOutsideCheck(candidate, rootReal);
745
+ try {
746
+ const stat = fs.statSync(candidate);
747
+ if (stat.isFile() && stat.nlink > 1) {
748
+ throw new Error('recovery receipt, journal, or claim has multiple hard links; manual review needed before recovery');
749
+ }
750
+ } catch (error) {
751
+ if (error && error.message && error.message.includes('multiple hard links')) throw error;
752
+ }
753
+ }
754
+ if (childReceiptDir) {
755
+ let realChild = null;
756
+ try {
757
+ realChild = realBoundaryPath(path.resolve(childReceiptDir));
758
+ } catch {
759
+ throw new Error('recovery child receipt directory could not be resolved safely outside the pack root; refusing recovery');
760
+ }
761
+ try {
762
+ const existing = fs.realpathSync(childReceiptDir);
763
+ realChild = existing;
764
+ } catch {
765
+ // Use boundary-resolved path above for not-yet-created directories.
766
+ }
767
+ if (insideRoot(realChild, rootReal) || realChild === rootReal) {
768
+ throw new Error('recovery child receipt directory must live outside the pack root; refusing recovery');
769
+ }
770
+ }
771
+ }
772
+
773
+ // Strict launch recovery shape. A recovered launch must carry a valid parent
774
+ // identity and an array of well-formed protected identities; absent or
775
+ // malformed recovered state refuses instead of silently becoming an empty
776
+ // protection set. The summary's recorded recovery identity must agree with
777
+ // the journal rather than letting a stale summary pass as fresh.
778
+ function requireInheritedProtection(launch, summary) {
779
+ const recovery = launch.recovery;
780
+ if (recovery !== undefined && recovery !== null && typeof recovery !== 'object') {
781
+ throw new Error('recovery journal is corrupt (malformed recovery metadata); refusing recovery rather than risk replay');
782
+ }
783
+ const mode = recovery && typeof recovery === 'object' ? recovery.mode : undefined;
784
+ if (mode !== undefined && mode !== 'fresh' && mode !== 'recovered') {
785
+ throw new Error('recovery journal is corrupt (unknown recovery mode); refusing recovery rather than risk replay');
786
+ }
787
+ let launchFiles = [];
788
+ if (mode === 'recovered') {
789
+ if (typeof recovery.parentRunId !== 'string' || !recovery.parentRunId
790
+ || typeof recovery.parentReceipt !== 'string' || !recovery.parentReceipt) {
791
+ throw new Error('recovery journal is corrupt (malformed recovered parent identity); refusing recovery rather than risk replay');
792
+ }
793
+ if (!Array.isArray(recovery.protectedFiles)) {
794
+ throw new Error('recovery journal is corrupt (malformed recovered protection); refusing recovery rather than risk replay');
795
+ }
796
+ for (const entry of recovery.protectedFiles) {
797
+ if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(entry.sha256)) {
798
+ throw new Error('recovery journal is corrupt (malformed protected entry); refusing recovery rather than risk replay');
799
+ }
800
+ }
801
+ launchFiles = recovery.protectedFiles;
802
+ } else if (recovery && recovery.protectedFiles !== undefined
803
+ && (!Array.isArray(recovery.protectedFiles) || recovery.protectedFiles.length !== 0)) {
804
+ throw new Error('recovery journal is corrupt (malformed fresh protection); refusing recovery rather than risk replay');
805
+ }
806
+ if (!summary) return launchFiles;
807
+ const summaryRecovery = summary.recovery;
808
+ if (!summaryRecovery || typeof summaryRecovery !== 'object') {
809
+ throw new Error('recovery summary is missing stable recovery metadata; refusing recovery rather than risk replay');
810
+ }
811
+ if (mode === 'recovered') {
812
+ if (summaryRecovery.mode !== undefined && summaryRecovery.mode !== 'recovered') {
813
+ throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
814
+ }
815
+ if ((summaryRecovery.parentRunId || null) !== recovery.parentRunId
816
+ || (summaryRecovery.parentReceipt || null) !== recovery.parentReceipt) {
817
+ throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
818
+ }
819
+ } else if (summaryRecovery.mode === 'recovered') {
820
+ throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
821
+ }
822
+ return launchFiles;
823
+ }
824
+
825
+ // Strict tool ceiling. The journal's granted tools must exactly equal the
826
+ // canonical tools implied by its validated capabilities: no extras, unknown
827
+ // names, duplicates, or reorderings. Stable summary evidence must agree.
828
+ function requireExactToolCeiling(launch, summary) {
829
+ const granted = launch.grantedCapabilities;
830
+ if (!Array.isArray(granted)) {
831
+ throw new Error('recovery journal is corrupt (missing granted capabilities)');
832
+ }
833
+ const seenCaps = new Set();
834
+ for (const cap of granted) {
835
+ if (typeof cap !== 'string' || !CAPABILITY_DEFINITIONS[cap]) {
836
+ throw new Error('recovery journal is corrupt (unknown granted capability); refusing recovery rather than risk replay');
837
+ }
838
+ if (seenCaps.has(cap)) {
839
+ throw new Error('recovery journal is corrupt (duplicate granted capability); refusing recovery rather than risk replay');
840
+ }
841
+ seenCaps.add(cap);
842
+ }
843
+ if (!Array.isArray(launch.requestedCapabilities)) {
844
+ throw new Error('recovery journal is corrupt (missing tool ceiling); refusing recovery rather than risk replay');
845
+ }
846
+ const seenRequested = new Set();
847
+ for (const cap of launch.requestedCapabilities) {
848
+ if (typeof cap !== 'string' || !CAPABILITY_DEFINITIONS[cap]) {
849
+ throw new Error('recovery journal is corrupt (unknown requested capability); refusing recovery rather than risk replay');
850
+ }
851
+ if (seenRequested.has(cap)) {
852
+ throw new Error('recovery journal is corrupt (duplicate requested capability); refusing recovery rather than risk replay');
853
+ }
854
+ seenRequested.add(cap);
855
+ if (!seenCaps.has(cap)) {
856
+ throw new Error('recovery journal is corrupt (requested capability outside granted set); refusing recovery rather than risk replay');
857
+ }
858
+ }
859
+ const expectedTools = toolsForCapabilities(granted);
860
+ if (!Array.isArray(launch.grantedTools) || launch.grantedTools.length !== expectedTools.length
861
+ || launch.grantedTools.some((tool, index) => tool !== expectedTools[index])) {
862
+ throw new Error('recovery journal is corrupt (tool ceiling does not match granted capabilities); refusing recovery rather than risk replay');
863
+ }
864
+ if (!summary || typeof summary !== 'object') {
865
+ throw new Error('recovery receipt is corrupt (unrecognized pack run receipt)');
866
+ }
867
+ if (!Array.isArray(summary.grantedCapabilities)
868
+ || [...summary.grantedCapabilities].sort().join(',') !== [...granted].sort().join(',')) {
869
+ throw new Error('recovery summary conflicts with its journal capabilities; refusing recovery rather than risk replay');
870
+ }
871
+ if (!Array.isArray(summary.grantedTools)
872
+ || summary.grantedTools.length !== expectedTools.length
873
+ || summary.grantedTools.some((tool, index) => tool !== expectedTools[index])) {
874
+ throw new Error('recovery summary conflicts with its journal tool ceiling; refusing recovery rather than risk replay');
875
+ }
876
+ if (!Array.isArray(summary.requestedCapabilities)
877
+ || summary.requestedCapabilities.length !== launch.requestedCapabilities.length
878
+ || summary.requestedCapabilities.some((cap, index) => cap !== launch.requestedCapabilities[index])) {
879
+ throw new Error('recovery summary conflicts with its journal capability request; refusing recovery rather than risk replay');
880
+ }
881
+ return granted;
882
+ }
883
+
884
+ // Read-only assessment of a parent receipt. Throws a plain error for every
885
+ // refusal; returns the inherited protection set when recovery may proceed.
886
+ // The companion event log is authoritative: exit, caps, pack identity,
887
+ // input, inherited protection, and journal version all derive from launch
888
+ // and exit events. The summary snapshot must agree; a stale summary never
889
+ // grants authority.
890
+ function assessPackRecoveryJournal(options = {}) {
891
+ const packDir = options.packDir;
892
+ const manifest = options.manifest || {};
893
+ const policy = options.policy || {};
894
+ const parentReceiptPath = path.resolve(String(options.parentReceiptPath || ''));
895
+ const operatorInput = options.operatorInput || null;
896
+ const childReceiptDir = options.childReceiptDir ? path.resolve(String(options.childReceiptDir)) : null;
897
+
898
+ let summary = null;
899
+ try {
900
+ summary = JSON.parse(fs.readFileSync(parentReceiptPath, 'utf8'));
901
+ } catch (error) {
902
+ if (error && error.code === 'ENOENT') throw new Error(`recovery receipt not found: ${parentReceiptPath}`);
903
+ throw new Error(`recovery receipt is corrupt or unreadable: ${error.message}`);
904
+ }
905
+ if (!summary || typeof summary !== 'object' || summary.schema !== 'atris.pack-run.v1' || typeof summary.runId !== 'string') {
906
+ throw new Error('recovery receipt is corrupt (unrecognized pack run receipt)');
907
+ }
908
+ if (typeof summary.events !== 'string' || !summary.events || summary.events.includes('/') || summary.events.includes('\\')) {
909
+ throw new Error('recovery journal binding is corrupt (events name); refusing recovery rather than risk replay');
910
+ }
911
+ const root = fs.realpathSync(packDir);
912
+ const eventsPath = path.join(path.dirname(parentReceiptPath), path.basename(summary.events));
913
+ const claimPath = packRecoveryClaimPath(parentReceiptPath);
914
+ assertRecoveryPlacementOutsideRoot({ rootReal: root, parentReceiptPath, eventsPath, claimPath, childReceiptDir });
915
+ let events = null;
916
+ try {
917
+ events = readStrictPackEvents(eventsPath);
918
+ } catch (error) {
919
+ if (error && error.code === 'ENOENT') {
920
+ throw new Error('recovery journal is missing alongside the receipt; refusing recovery rather than risk replay');
921
+ }
922
+ throw error;
923
+ }
924
+ if (!events.length) throw new Error('recovery journal is corrupt (empty history); refusing recovery rather than risk replay');
925
+ const launches = events.filter((event) => event.event === 'launch');
926
+ if (launches.length !== 1 || events[0].event !== 'launch') {
927
+ throw new Error('recovery journal is corrupt (launch must be first and only); refusing recovery rather than risk replay');
928
+ }
929
+ const launch = launches[0];
930
+ if (!launch.journal || launch.journal.supported !== true || launch.journal.version !== PACK_JOURNAL_VERSION) {
931
+ throw new Error('recovery needs a journaled run; this receipt predates file-effect journaling, so inspect the prior run with manual review');
932
+ }
933
+ if (summary.journal && (summary.journal.supported !== launch.journal.supported || summary.journal.version !== launch.journal.version)) {
934
+ throw new Error('recovery journal conflicts with its receipt summary; refusing recovery rather than risk replay');
935
+ }
936
+ if (launch.runId !== summary.runId) {
937
+ throw new Error('recovery journal is corrupt (launch mismatch); refusing recovery rather than risk replay');
938
+ }
939
+ const exits = events.filter((event) => event.event === 'exit');
940
+ if (exits.length === 0) {
941
+ throw new Error('parent run has no recorded runner exit; recovery needs a nonzero runner exit, so inspect the prior run with manual review');
942
+ }
943
+ if (exits.length !== 1 || events[events.length - 1].event !== 'exit') {
944
+ throw new Error('recovery journal is corrupt (terminal exit must be last and only); refusing recovery rather than risk replay');
945
+ }
946
+ const journalExit = exits[0];
947
+ if (typeof journalExit.status !== 'number' && !journalExit.signal) {
948
+ throw new Error('parent run has no recorded runner exit status; refusing recovery rather than risk replay');
949
+ }
950
+ if (journalExit.status === 0 && !journalExit.signal) {
951
+ throw new Error('parent run succeeded (exit 0); nothing to recover');
952
+ }
953
+ if (summary.status !== 'finished' || summary.exitStatus !== journalExit.status
954
+ || (summary.signal || null) !== (journalExit.signal || null)) {
955
+ throw new Error('recovery summary conflicts with its journal exit; refusing recovery rather than risk replay');
956
+ }
957
+ const expectedEvents = `${path.basename(parentReceiptPath, '.json')}.events.jsonl`;
958
+ if (path.basename(summary.events) !== expectedEvents) {
959
+ throw new Error('recovery journal binding does not match this receipt; refusing recovery rather than risk replay');
960
+ }
961
+ const journalGranted = requireExactToolCeiling(launch, summary);
962
+ for (const event of events) {
963
+ if (!['intent', 'used', 'failed'].includes(event.event)) continue;
964
+ if (!launch.grantedTools.includes(event.tool)
965
+ || (event.capability !== undefined && event.capability !== toolCapability(event.tool, journalGranted))) {
966
+ throw new Error('recovery journal has a tool ceiling mismatch (corrupt history); refusing recovery rather than risk replay');
967
+ }
968
+ }
969
+ if (journalGranted.includes('host.shell')) {
970
+ throw new Error('parent run granted host.shell; shell effects cannot be reasoned about, so inspect the prior run with manual review');
971
+ }
972
+ if (!policy || policy.status !== 'enforced') {
973
+ throw new Error('pack run --recover needs a declared capability ceiling; legacy packs cannot be recovered');
974
+ }
975
+ if (policy.grantedCapabilities.includes('host.shell')) {
976
+ throw new Error('pack run --recover cannot grant host.shell; shell effects cannot be reasoned about');
977
+ }
978
+ if ([...journalGranted].sort().join(',') !== [...policy.grantedCapabilities].sort().join(',')) {
979
+ throw new Error(`capabilities changed since the parent run (was: ${journalGranted.join(', ') || 'none'}); recovery needs the same granted set`);
980
+ }
981
+ let launchRoot = null;
982
+ try {
983
+ launchRoot = launch.pack && launch.pack.root ? fs.realpathSync(launch.pack.root) : null;
984
+ } catch {
985
+ launchRoot = null;
986
+ }
987
+ if (!launch.pack || typeof launch.pack.slug !== 'string' || typeof launch.pack.version !== 'string' || launchRoot !== root) {
988
+ throw new Error('recovery journal belongs to a different pack, folder, or version; refusing to mix histories');
989
+ }
990
+ if (summary.pack && (summary.pack.slug !== launch.pack.slug || summary.pack.version !== launch.pack.version)) {
991
+ throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
992
+ }
993
+ try {
994
+ const summaryRoot = summary.pack && summary.pack.root ? fs.realpathSync(summary.pack.root) : null;
995
+ if (summaryRoot && summaryRoot !== launchRoot) {
996
+ throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
997
+ }
998
+ } catch {
999
+ throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
1000
+ }
1001
+ if (!launch.pack || launch.pack.slug !== manifest.slug || launch.pack.version !== manifest.version) {
1002
+ throw new Error('recovery receipt belongs to a different pack, folder, or version; refusing to mix histories');
1003
+ }
1004
+ const journalInput = launch.operatorInput ? { bytes: launch.operatorInput.bytes, sha256: launch.operatorInput.sha256 } : null;
1005
+ const summaryInput = summary.operatorInput ? { bytes: summary.operatorInput.bytes, sha256: summary.operatorInput.sha256 } : null;
1006
+ if (JSON.stringify(summaryInput) !== JSON.stringify(journalInput)) {
1007
+ throw new Error('recovery summary conflicts with its journal input; refusing recovery rather than risk replay');
1008
+ }
1009
+ const currentInput = operatorInput ? { bytes: operatorInput.bytes, sha256: operatorInput.sha256 } : null;
1010
+ if (JSON.stringify(journalInput) !== JSON.stringify(currentInput)) {
1011
+ throw new Error('operator input differs from the parent run; recovery needs the same input');
1012
+ }
1013
+ const inherited = requireInheritedProtection(launch, summary);
1014
+ for (const entry of inherited) {
1015
+ assertJournalTarget(entry.target, root);
1016
+ }
1017
+ const effects = pairPackFileEffects(events);
1018
+ for (const item of [...effects.confirmed, ...effects.unresolved, ...effects.failed]) {
1019
+ if (item.target !== null && item.target !== undefined) assertJournalTarget(item.target, root);
1020
+ }
1021
+ // No missing identity may pass recovery, and any intent without its
1022
+ // confirmation (the crash window, or an interactive denial) refuses.
1023
+ // Inspect the prior run; a fresh rerun would risk replaying unknown effects.
1024
+ if (effects.unresolved.length) {
1025
+ const first = effects.unresolved[0];
1026
+ throw new Error(`journal has ${effects.unresolved.length} unresolved file effect(s) (first: ${first.tool || '?'} ${first.target || '?'}: ${first.reason}); inspect the prior run with manual review`);
1027
+ }
1028
+ const lastConfirmed = new Map();
1029
+ for (const entry of effects.confirmed) lastConfirmed.set(entry.target, entry);
1030
+ const inheritedByTarget = new Map();
1031
+ for (const entry of inherited) inheritedByTarget.set(entry.target, entry);
1032
+ const protectedFiles = mergeProtectedFiles(inherited, [...lastConfirmed.values()]);
1033
+ // Recompute both views from the journal, never use one summary field to
1034
+ // justify another. Lost completed or pending events must not become a
1035
+ // shorter, apparently safe history beside an unchanged receipt.
1036
+ if (!isDeepStrictEqual(summary.fileEffects, effects)) {
1037
+ throw new Error('recovery summary file effects conflict with its journal (corrupt history); refusing recovery rather than risk replay');
1038
+ }
1039
+ if (!isDeepStrictEqual(summary.recovery.protectedFiles, protectedFiles)) {
1040
+ throw new Error('recovery summary protection conflicts with its journal protection; refusing recovery rather than risk replay');
1041
+ }
1042
+ for (const entry of protectedFiles) {
1043
+ const abs = path.resolve(root, entry.target);
1044
+ let stat = null;
1045
+ try {
1046
+ stat = fs.statSync(abs);
1047
+ } catch {
1048
+ throw new Error(`completed file ${entry.target} is missing or changed since the parent run; refusing recovery`);
1049
+ }
1050
+ if (!stat.isFile()) throw new Error(`completed file ${entry.target} is missing or changed since the parent run; refusing recovery`);
1051
+ if (stat.nlink > 1) throw new Error(`completed file ${entry.target} has multiple hard links; manual review needed before recovery`);
1052
+ if (sha256Hex(fs.readFileSync(abs)) !== entry.sha256) {
1053
+ throw new Error(`completed file ${entry.target} changed since the parent run; refusing recovery`);
1054
+ }
1055
+ if (stat.dev !== entry.dev || stat.ino !== entry.ino) {
1056
+ throw new Error(`completed file ${entry.target} was replaced or aliased since the parent run; refusing recovery`);
1057
+ }
1058
+ }
1059
+ // A failed tool only counts as resolved when the bytes still match the last
1060
+ // confirmation for that target (including inherited confirmations), or the
1061
+ // never-confirmed target stayed absent.
1062
+ for (const item of effects.failed) {
1063
+ const abs = path.resolve(root, item.target);
1064
+ const last = lastConfirmed.get(item.target) || inheritedByTarget.get(item.target);
1065
+ if (last) {
1066
+ let current = null;
1067
+ try {
1068
+ current = sha256Hex(fs.readFileSync(abs));
1069
+ } catch {
1070
+ throw new Error(`file ${item.target} for a failed action is unreadable; refusing recovery`);
1071
+ }
1072
+ if (current !== last.sha256) {
1073
+ throw new Error(`failed action left ${item.target} different from its last confirmed state; refusing recovery`);
1074
+ }
1075
+ } else if (fs.existsSync(abs)) {
1076
+ throw new Error(`failed action may have changed ${item.target}; refusing recovery rather than risk replay`);
1077
+ }
1078
+ }
1079
+ return { parentRunId: launch.runId, parentSummary: summary, protectedFiles };
1080
+ }
1081
+
1082
+ // A parent receipt backs at most one child. The claim lives next to the
1083
+ // parent receipt, outside the pack root. Exclusive creation makes the single
1084
+ // child atomic; a claim with no child means the launcher crashed mid-claim
1085
+ // and needs manual review, never an automatic replay.
1086
+ function packRecoveryClaimPath(parentReceiptPath) {
1087
+ const dir = path.dirname(path.resolve(parentReceiptPath));
1088
+ return path.join(dir, `${path.basename(parentReceiptPath, '.json')}.claim.json`);
1089
+ }
1090
+
1091
+ function claimPackRecoveryParent(parentReceiptPath, parentRunId) {
1092
+ const claimPath = packRecoveryClaimPath(parentReceiptPath);
1093
+ const claim = {
1094
+ version: 1,
1095
+ parentReceipt: path.resolve(parentReceiptPath),
1096
+ parentRunId,
1097
+ claimedAt: new Date().toISOString(),
1098
+ childReceipt: null,
1099
+ };
1100
+ let descriptor = null;
1101
+ try {
1102
+ descriptor = fs.openSync(claimPath, 'wx', 0o600);
1103
+ } catch (error) {
1104
+ if (error && error.code === 'EEXIST') {
1105
+ let existing = null;
1106
+ try {
1107
+ existing = JSON.parse(fs.readFileSync(claimPath, 'utf8'));
1108
+ } catch {
1109
+ existing = null;
1110
+ }
1111
+ if (!existing || typeof existing !== 'object') {
1112
+ throw new Error(`a recovery claim for this receipt is unreadable; manual review needed at ${claimPath}`);
1113
+ }
1114
+ if (existing.childReceipt) {
1115
+ throw new Error(`this receipt already recovered as ${existing.childReceipt}; recover from that child instead`);
1116
+ }
1117
+ throw new Error(`a recovery claim is already in progress for this receipt; manual review needed at ${claimPath}`);
1118
+ }
1119
+ throw error;
1120
+ }
1121
+ try {
1122
+ fs.writeFileSync(descriptor, `${JSON.stringify(claim, null, 2)}\n`);
1123
+ } finally {
1124
+ fs.closeSync(descriptor);
1125
+ }
1126
+ return claimPath;
1127
+ }
1128
+
1129
+ function confirmPackRecoveryClaim(claimPath, childReceiptPath) {
1130
+ const claim = JSON.parse(fs.readFileSync(claimPath, 'utf8'));
1131
+ claim.childReceipt = path.resolve(childReceiptPath);
1132
+ fs.writeFileSync(claimPath, `${JSON.stringify(claim, null, 2)}\n`);
1133
+ }
1134
+
312
1135
  function defaultProcessExists(pid) {
313
1136
  try {
314
1137
  process.kill(pid, 0);
@@ -391,6 +1214,8 @@ function beginPackRunReceipt(packDir, manifest, policy, options = {}) {
391
1214
  },
392
1215
  ...(options.operatorInput ? { operatorInput: options.operatorInput } : {}),
393
1216
  approvalMode: options.trust ? 'pre-approved-within-declared-ceiling' : 'prompt-within-declared-ceiling',
1217
+ journal: { version: PACK_JOURNAL_VERSION, supported: true },
1218
+ recovery: sanitizeLaunchRecovery(options.recovery),
394
1219
  requestedCapabilities: policy.requested,
395
1220
  grantedCapabilities: policy.grantedCapabilities,
396
1221
  grantedTools: policy.tools,
@@ -629,16 +1454,42 @@ function runHook(mode, rawInput) {
629
1454
  if (!decision.allowed) {
630
1455
  return denyPreToolUse(env, input, decision.reason, now);
631
1456
  }
1457
+ // Denied mutations return above, so they never leave a pending intent.
1458
+ const protection = checkProtectedMutation(env, input);
1459
+ if (protection && protection.denied) {
1460
+ return denyPreToolUse(env, input, protection.reason, now);
1461
+ }
1462
+ journalFileIntent(env, input);
632
1463
  return null;
633
1464
  }
634
1465
 
635
1466
  if (mode === 'used' || mode === 'failed') {
636
- appendReceiptEvent(env.eventsPath, {
1467
+ const entry = {
637
1468
  event: mode,
638
1469
  at: now,
639
1470
  tool: input.tool_name || null,
640
1471
  capability: toolCapability(input.tool_name, env.grantedCapabilities),
641
- });
1472
+ };
1473
+ if (FILE_MUTATION_TOOLS.has(input.tool_name)) {
1474
+ entry.tool_use_id = hookToolUseId(input);
1475
+ try {
1476
+ entry.target = journalRelativeTarget(input.tool_name, input.tool_input || {}, env.root);
1477
+ if (entry.target) {
1478
+ const identity = readFileIdentity(path.resolve(fs.realpathSync(env.root), entry.target));
1479
+ entry.targetPresent = identity.present;
1480
+ if (identity.present && identity.isFile) {
1481
+ entry.fileSha256 = identity.sha256;
1482
+ entry.dev = identity.dev;
1483
+ entry.ino = identity.ino;
1484
+ entry.nlink = identity.nlink;
1485
+ }
1486
+ }
1487
+ } catch {
1488
+ // A confirmation that cannot read its file stays deliberately thin;
1489
+ // pairing treats it as unresolved rather than confirmed.
1490
+ }
1491
+ }
1492
+ appendReceiptEvent(env.eventsPath, entry);
642
1493
  } else if (mode === 'session-end') {
643
1494
  const reason = CLAUDE_SESSION_END_REASONS.has(input.reason) ? input.reason : 'other';
644
1495
  appendReceiptEvent(env.eventsPath, { event: 'session-end', at: now, reason });
@@ -656,6 +1507,9 @@ async function runHookAsync(mode, rawInput, options = {}) {
656
1507
  const now = new Date().toISOString();
657
1508
  const fileDecision = decidePreToolUse(input, env);
658
1509
  if (!fileDecision.allowed) return denyPreToolUse(env, input, fileDecision.reason, now);
1510
+ const protection = checkProtectedMutation(env, input);
1511
+ if (protection && protection.denied) return denyPreToolUse(env, input, protection.reason, now);
1512
+ journalFileIntent(env, input);
659
1513
  const webDecision = await enforcePublicWeb(input, options);
660
1514
  if (!webDecision.allowed) return denyPreToolUse(env, input, webDecision.reason, now);
661
1515
  return null;
@@ -692,4 +1546,8 @@ module.exports = {
692
1546
  enforcePublicWeb,
693
1547
  runHook,
694
1548
  runHookAsync,
1549
+ assessPackRecoveryJournal,
1550
+ packRecoveryClaimPath,
1551
+ claimPackRecoveryParent,
1552
+ confirmPackRecoveryClaim,
695
1553
  };