@open-agent-toolkit/cli 0.3.20 → 0.3.22

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 (116) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/reference/cli-reference.md +8 -3
  3. package/assets/docs/reference/configuration.md +3 -0
  4. package/assets/docs/reference/oat-directory-structure.md +1 -1
  5. package/assets/docs/reference/project-artifacts.md +141 -2
  6. package/assets/docs/reference/project-state-machine.md +2 -2
  7. package/assets/docs/workflows/advanced/autonomy.md +29 -12
  8. package/assets/docs/workflows/advanced/cursor-cloud.md +20 -6
  9. package/assets/docs/workflows/advanced/workflow-gates.md +9 -0
  10. package/assets/docs/workflows/projects/closeout/closeout-skills.md +24 -5
  11. package/assets/docs/workflows/projects/closeout/retro.md +6 -2
  12. package/assets/docs/workflows/projects/execution/implementation-execution.md +18 -4
  13. package/assets/docs/workflows/projects/execution/index.md +1 -1
  14. package/assets/docs/workflows/projects/execution/picking-up-projects.md +3 -2
  15. package/assets/docs/workflows/projects/execution/project-log.md +100 -11
  16. package/assets/docs/workflows/projects/lifecycle.md +3 -3
  17. package/assets/docs/workflows/projects/reviews/index.md +18 -0
  18. package/assets/public-package-versions.json +4 -4
  19. package/assets/skills/oat-cursor-cloud-projects/SKILL.md +12 -8
  20. package/assets/skills/oat-cursor-cloud-projects/references/cursor-cloud-mechanics.md +63 -2
  21. package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
  22. package/assets/skills/oat-dispatch-subagents/references/record-schema.md +6 -0
  23. package/assets/skills/oat-project-autonomous/SKILL.md +88 -26
  24. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  25. package/assets/skills/oat-project-brief/SKILL.md +1 -1
  26. package/assets/skills/oat-project-brief/references/brief-contract.md +40 -27
  27. package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +1 -1
  28. package/assets/skills/oat-project-complete/SKILL.md +142 -25
  29. package/assets/skills/oat-project-complete-auto/SKILL.md +4 -4
  30. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +10 -5
  31. package/assets/skills/oat-project-document/SKILL.md +1 -1
  32. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  33. package/assets/skills/oat-project-goal/SKILL.md +63 -15
  34. package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +1 -1
  35. package/assets/skills/oat-project-implement/SKILL.md +6 -6
  36. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +28 -7
  37. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +10 -5
  38. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  39. package/assets/skills/oat-project-implement/references/phase-execution.md +23 -9
  40. package/assets/skills/oat-project-implement/references/plan-and-resume.md +4 -4
  41. package/assets/skills/oat-project-lite/SKILL.md +1 -1
  42. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
  43. package/assets/skills/oat-project-pr-final/SKILL.md +6 -1
  44. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  45. package/assets/skills/oat-project-pr-progress/SKILL.md +6 -1
  46. package/assets/skills/oat-project-quick-start/SKILL.md +1 -1
  47. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  48. package/assets/skills/oat-project-reconcile/SKILL.md +13 -19
  49. package/assets/skills/oat-project-retro/SKILL.md +5 -2
  50. package/assets/skills/oat-project-retro/references/evidence-and-lanes.md +9 -3
  51. package/assets/skills/oat-project-review-provide/SKILL.md +28 -3
  52. package/assets/skills/oat-project-review-receive/SKILL.md +32 -18
  53. package/assets/skills/oat-project-review-receive-remote/SKILL.md +10 -6
  54. package/assets/skills/oat-project-revise/SKILL.md +9 -2
  55. package/assets/skills/oat-project-summary/SKILL.md +166 -37
  56. package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +3 -3
  57. package/assets/templates/implementation-brief.md +5 -36
  58. package/assets/templates/implementation.md +11 -81
  59. package/assets/templates/project-log.md +2 -31
  60. package/assets/templates/project-retro.md +4 -1
  61. package/assets/templates/summary.md +13 -5
  62. package/dist/commands/config/index.d.ts.map +1 -1
  63. package/dist/commands/config/index.js +44 -1
  64. package/dist/commands/project/archive/archive-utils.d.ts +205 -8
  65. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  66. package/dist/commands/project/archive/archive-utils.js +708 -44
  67. package/dist/commands/project/archive/index.d.ts.map +1 -1
  68. package/dist/commands/project/archive/index.js +1 -0
  69. package/dist/commands/project/archive/push-runner.d.ts +9 -1
  70. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  71. package/dist/commands/project/archive/push-runner.js +56 -18
  72. package/dist/commands/project/archive/sync-runner.d.ts +28 -0
  73. package/dist/commands/project/archive/sync-runner.d.ts.map +1 -1
  74. package/dist/commands/project/archive/sync-runner.js +44 -22
  75. package/dist/commands/project/complete-state/index.d.ts.map +1 -1
  76. package/dist/commands/project/complete-state/index.js +45 -0
  77. package/dist/commands/project/dispatch/index.d.ts.map +1 -1
  78. package/dist/commands/project/dispatch/index.js +24 -2
  79. package/dist/commands/project/dispatch/record.d.ts +54 -1
  80. package/dist/commands/project/dispatch/record.d.ts.map +1 -1
  81. package/dist/commands/project/dispatch/record.js +229 -1
  82. package/dist/commands/project/index.d.ts.map +1 -1
  83. package/dist/commands/project/index.js +2 -0
  84. package/dist/commands/project/journal-check/evaluator.d.ts +17 -0
  85. package/dist/commands/project/journal-check/evaluator.d.ts.map +1 -0
  86. package/dist/commands/project/journal-check/evaluator.js +140 -0
  87. package/dist/commands/project/journal-check/index.d.ts +34 -0
  88. package/dist/commands/project/journal-check/index.d.ts.map +1 -0
  89. package/dist/commands/project/journal-check/index.js +163 -0
  90. package/dist/commands/project/journal-check/rules.d.ts +68 -0
  91. package/dist/commands/project/journal-check/rules.d.ts.map +1 -0
  92. package/dist/commands/project/journal-check/rules.js +994 -0
  93. package/dist/commands/project/journal-check/types.d.ts +63 -0
  94. package/dist/commands/project/journal-check/types.d.ts.map +1 -0
  95. package/dist/commands/project/journal-check/types.js +26 -0
  96. package/dist/commands/project/log/append.d.ts +6 -0
  97. package/dist/commands/project/log/append.d.ts.map +1 -1
  98. package/dist/commands/project/log/append.js +114 -6
  99. package/dist/commands/project/log/check.d.ts +23 -1
  100. package/dist/commands/project/log/check.d.ts.map +1 -1
  101. package/dist/commands/project/log/check.js +20 -3
  102. package/dist/commands/project/log/grammar.d.ts +37 -0
  103. package/dist/commands/project/log/grammar.d.ts.map +1 -1
  104. package/dist/commands/project/log/grammar.js +72 -0
  105. package/dist/commands/project/log/rollup.d.ts +87 -1
  106. package/dist/commands/project/log/rollup.d.ts.map +1 -1
  107. package/dist/commands/project/log/rollup.js +257 -5
  108. package/dist/config/oat-config.d.ts +14 -0
  109. package/dist/config/oat-config.d.ts.map +1 -1
  110. package/dist/config/oat-config.js +17 -0
  111. package/dist/config/resolve.d.ts.map +1 -1
  112. package/dist/config/resolve.js +5 -1
  113. package/dist/providers/identity/generic-dispatch-record.d.ts +33 -0
  114. package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -1
  115. package/dist/providers/identity/generic-dispatch-record.js +60 -1
  116. package/package.json +2 -2
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { link, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
4
4
  import { basename, dirname, isAbsolute, join, posix as path, relative, resolve, sep, } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { PROJECT_LOG_LOCATOR_PREFIX } from '../../project/log/rollup.js';
6
7
  import { defaultGitRunner } from '../../project/sync/git.js';
7
8
  import { readSyncedRecord, writeSyncedRecord, } from '../../project/sync/record.js';
8
9
  import { buildSyncTarget, commitRecordChange, preflightSyncedCheckout, removeSyncedCheckout, retireSyncedRef, } from '../../project/sync/ref-sync.js';
@@ -15,10 +16,37 @@ import { loadExplainerPackageCoverage } from './explainer-package-coverage.js';
15
16
  const execFileAsync = promisify(execFileCallback);
16
17
  export const ARCHIVE_SNAPSHOT_METADATA_FILENAME = '.oat-archive-source.json';
17
18
  /**
18
- * Directories excluded from S3 archive sync. These contain process artifacts
19
- * (reviews, PR descriptions) rather than project deliverables.
19
+ * Top-level project entries an archive never retains: review and PR evidence
20
+ * are process artifacts, not project deliverables (D2), and a synced
21
+ * checkout's `.git` link is not project content. This one list defines the
22
+ * retained archive: the local copy, the retained-artifact manifest, the S3
23
+ * upload and the S3 hydration all read it, so an archive restored from S3
24
+ * hashes equal to the archive that was uploaded.
20
25
  */
21
- export const S3_ARCHIVE_SYNC_EXCLUDES = ['reviews/*', 'pr/*'];
26
+ export const ARCHIVE_RETENTION_EXCLUSIONS = [
27
+ 'reviews',
28
+ 'pr',
29
+ '.git',
30
+ ];
31
+ /**
32
+ * `aws s3 sync --exclude` patterns for the upload and the hydration, derived
33
+ * from `ARCHIVE_RETENTION_EXCLUSIONS` so S3 never holds or restores a
34
+ * top-level entry the manifest does not retain.
35
+ */
36
+ export const S3_ARCHIVE_SYNC_EXCLUDES = ARCHIVE_RETENTION_EXCLUSIONS.map((entry) => `${entry}/*`);
37
+ /**
38
+ * Top-level entries the archive itself owns. They are derived at archive time,
39
+ * so the manifest that identifies the retained project content excludes them.
40
+ */
41
+ export const ARCHIVE_OWNED_ENTRIES = [
42
+ ARCHIVE_SNAPSHOT_METADATA_FILENAME,
43
+ ];
44
+ /**
45
+ * The prefix an exported summary's finalized source locator carries:
46
+ * `oat-archive:<snapshot>#<entry-id>`, where `<snapshot>` is the persisted
47
+ * dated export identity (also the summary export file stem and the S3 key).
48
+ */
49
+ export const ARCHIVE_LOCATOR_PREFIX = 'oat-archive:';
22
50
  export function assertExactArchiveProjectRoot(options) {
23
51
  const projectScope = resolveProjectScope(options.projectPath, resolveScopeRoot(options.repoRoot, options.projectsRoot, 'shared'), options.repoRoot);
24
52
  if (!projectScope) {
@@ -103,29 +131,54 @@ function normalizeArchiveDateStamp(timestamp) {
103
131
  export function buildArchiveSnapshotName(projectName, timestamp) {
104
132
  return `${normalizeArchiveDateStamp(timestamp)}-${projectName}`;
105
133
  }
134
+ /**
135
+ * The one archive naming contract, shared by the writer and every reader:
136
+ * local name `<slug>[-gN][-<scope>]` and snapshot (export identity)
137
+ * `<YYYYMMDD>-<local-name>`. `-gN` (N >= 2) names a successor generation;
138
+ * `-<scope>` qualifies an identity whose unqualified name another scope's
139
+ * archive already holds.
140
+ */
141
+ export function formatArchiveLocalName(projectName, generation, scope) {
142
+ return `${projectName}${generation > 1 ? `-g${generation}` : ''}${scope ? `-${scope}` : ''}`;
143
+ }
144
+ const ARCHIVE_LOCAL_NAME_PATTERN = /^(.+?)(?:-g([2-9]|[1-9]\d+))?(?:-(shared|local|synced))?$/;
145
+ /**
146
+ * Parses an S3 snapshot name under the naming contract of
147
+ * `formatArchiveLocalName`. The local name is the archive directory the
148
+ * snapshot hydrates to, so generation 2 of `<project>` restores to
149
+ * `<project>-g2` (or `<project>-g2-<scope>`), never over generation 1. An
150
+ * undated name is its own local, family and project name. A project whose own
151
+ * slug ends in `-gN` or `-<scope>` reads as that generation or qualifier: the
152
+ * residual ambiguity the by-name sync precedence documents.
153
+ */
106
154
  export function parseArchiveSnapshotName(snapshotName) {
107
155
  const trimmedSnapshot = snapshotName.replace(/\/+$/, '');
108
156
  const match = trimmedSnapshot.match(/^(\d{8})-(.+)$/);
109
- if (!match) {
110
- return {
111
- projectName: trimmedSnapshot,
112
- snapshotName: trimmedSnapshot,
113
- dateStamp: null,
114
- };
115
- }
116
- const dateStamp = match[1];
117
- const projectName = match[2];
118
- if (!dateStamp || !projectName) {
157
+ const dateStamp = match?.[1];
158
+ const localName = match?.[2];
159
+ if (!dateStamp || !localName) {
119
160
  return {
120
161
  projectName: trimmedSnapshot,
162
+ localName: trimmedSnapshot,
163
+ familyName: trimmedSnapshot,
164
+ scope: null,
121
165
  snapshotName: trimmedSnapshot,
122
166
  dateStamp: null,
167
+ generation: 1,
123
168
  };
124
169
  }
170
+ const parts = localName.match(ARCHIVE_LOCAL_NAME_PATTERN);
171
+ const projectName = parts?.[1] ?? localName;
172
+ const generation = parts?.[2] ? Number(parts[2]) : 1;
173
+ const scope = parts?.[3] ?? null;
125
174
  return {
126
175
  projectName,
176
+ localName,
177
+ familyName: formatArchiveLocalName(projectName, 1, scope),
178
+ scope,
127
179
  snapshotName: trimmedSnapshot,
128
180
  dateStamp,
181
+ generation,
129
182
  };
130
183
  }
131
184
  export function resolveLocalArchiveProjectPath(projectsRoot, projectName) {
@@ -274,7 +327,7 @@ async function resolvePersistedArchivePath(archiveBasePath, projectName, snapsho
274
327
  }
275
328
  }
276
329
  if (matches.length > 1) {
277
- throw new CliError(`Persisted archive snapshot \`${snapshotName}\` resolves to multiple local archives; refusing an ambiguous retry.`);
330
+ throw new ArchiveIdentityError('archive-retry-ambiguous', `Persisted archive snapshot \`${snapshotName}\` resolves to multiple local archives; refusing an ambiguous retry.`);
278
331
  }
279
332
  return matches[0] ?? join(archiveRoot, snapshotName);
280
333
  }
@@ -337,6 +390,476 @@ export function assertDurableArchiveProjectTarget(target) {
337
390
  throw new CliError(target.localOnlyWarning);
338
391
  }
339
392
  }
393
+ /**
394
+ * Hashes the canonical retained-artifact manifest of a project or archive
395
+ * directory: every regular file below `root`, except the top-level
396
+ * `ARCHIVE_RETENTION_EXCLUSIONS` and `ARCHIVE_OWNED_ENTRIES`, as sorted
397
+ * `<sha256> <posix path>` lines. A source project and the archive copied from
398
+ * it therefore hash equal, so an identical retry is recognized from either
399
+ * side. Symlinks and empty directories are not retained by the copy and are
400
+ * not hashed. Read-only.
401
+ */
402
+ export async function computeArchiveManifestHash(root) {
403
+ const excluded = new Set([
404
+ ...ARCHIVE_RETENTION_EXCLUSIONS,
405
+ ...ARCHIVE_OWNED_ENTRIES,
406
+ ]);
407
+ const lines = [];
408
+ const walk = async (directory, prefix) => {
409
+ const entries = await readdir(directory, { withFileTypes: true });
410
+ for (const entry of entries) {
411
+ if (prefix === '' && excluded.has(entry.name))
412
+ continue;
413
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
414
+ const absolutePath = join(directory, entry.name);
415
+ if (entry.isDirectory()) {
416
+ await walk(absolutePath, relativePath);
417
+ }
418
+ else if (entry.isFile()) {
419
+ lines.push(`${sha256(await readFile(absolutePath))} ${relativePath}`);
420
+ }
421
+ }
422
+ };
423
+ await walk(root, '');
424
+ lines.sort();
425
+ return sha256(lines.join('\n'));
426
+ }
427
+ function readCandidateMetadata(value) {
428
+ if (!isRecord(value) ||
429
+ typeof value.projectName !== 'string' ||
430
+ typeof value.snapshotName !== 'string' ||
431
+ (value.scope !== 'shared' &&
432
+ value.scope !== 'local' &&
433
+ value.scope !== 'synced')) {
434
+ return null;
435
+ }
436
+ return {
437
+ projectName: value.projectName,
438
+ snapshotName: value.snapshotName,
439
+ scope: value.scope,
440
+ ...(typeof value.sourceRefSha === 'string'
441
+ ? { sourceRefSha: value.sourceRefSha }
442
+ : {}),
443
+ ...(isSha256(value.manifestHash)
444
+ ? { manifestHash: value.manifestHash }
445
+ : {}),
446
+ ...(typeof value.generation === 'number' &&
447
+ Number.isInteger(value.generation) &&
448
+ value.generation > 0
449
+ ? { generation: value.generation }
450
+ : {}),
451
+ ...(typeof value.predecessor === 'string'
452
+ ? { predecessor: value.predecessor }
453
+ : {}),
454
+ ...(isSha256(value.generationRequest)
455
+ ? { generationRequest: value.generationRequest }
456
+ : {}),
457
+ ...(typeof value.generationReason === 'string'
458
+ ? { generationReason: value.generationReason }
459
+ : {}),
460
+ ...(value.allocation === 'pending'
461
+ ? { allocation: 'pending' }
462
+ : {}),
463
+ };
464
+ }
465
+ function generationOf(candidate) {
466
+ return candidate.metadata?.generation ?? 1;
467
+ }
468
+ function identityFromMetadata(metadata) {
469
+ return {
470
+ predecessor: metadata?.predecessor ?? null,
471
+ generationRequest: metadata?.generationRequest ?? null,
472
+ generationReason: metadata?.generationReason ?? null,
473
+ allocationPending: metadata?.allocation === 'pending',
474
+ };
475
+ }
476
+ /**
477
+ * The warning both apply and dry-run report when `--new-generation` resolved to
478
+ * an existing generation: the retained artifacts are identical to it, so no
479
+ * generation is allocated and the reason is not recorded.
480
+ */
481
+ export function newGenerationReplayWarning(identity, newGenerationReason) {
482
+ if (newGenerationReason == null || identity.disposition !== 'retry') {
483
+ return null;
484
+ }
485
+ const resolved = `\`--new-generation\` resolved to existing generation ${identity.generation} (\`${identity.snapshotName}\`): retained artifacts are identical to that archive`;
486
+ // Replaying an explicit generation: its reason is already persisted.
487
+ return identity.generationReason
488
+ ? `${resolved}, allocated for reason \`${identity.generationReason}\`; no new generation was allocated.`
489
+ : `${resolved}; the reason was not recorded.`;
490
+ }
491
+ /** Validates `--new-generation <reason>`: one non-empty line. */
492
+ export function validateNewGenerationReason(reason) {
493
+ const trimmed = reason.trim();
494
+ if (trimmed === '' || /[\r\n]/.test(trimmed) || trimmed.length > 200) {
495
+ throw new ArchiveIdentityError('archive-generation-reason', '--new-generation requires a non-empty, single-line reason of at most 200 characters.', 2);
496
+ }
497
+ return trimmed;
498
+ }
499
+ /**
500
+ * Every local archive that could hold this project: each directory whose
501
+ * metadata names the project (in any scope), plus any non-empty directory at
502
+ * the project's base archive path whose metadata does not.
503
+ */
504
+ async function readArchiveIdentityCandidates(archiveBasePath, projectName) {
505
+ const archiveRoot = dirname(archiveBasePath);
506
+ let entries;
507
+ try {
508
+ entries = await readdir(archiveRoot, { withFileTypes: true });
509
+ }
510
+ catch {
511
+ return [];
512
+ }
513
+ const candidates = [];
514
+ for (const entry of entries
515
+ .filter((candidate) => candidate.isDirectory())
516
+ .sort((left, right) => left.name.localeCompare(right.name))) {
517
+ const archivePath = join(archiveRoot, entry.name);
518
+ let metadata = null;
519
+ try {
520
+ metadata = readCandidateMetadata(JSON.parse(await readFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
521
+ }
522
+ catch {
523
+ metadata = null;
524
+ }
525
+ if (metadata?.projectName === projectName) {
526
+ candidates.push({ archivePath, metadata });
527
+ }
528
+ else if (archivePath === archiveBasePath &&
529
+ (await readdir(archivePath)).length > 0) {
530
+ // An empty directory holds no archive; anything else at the base path
531
+ // that does not identify itself as this project cannot be verified.
532
+ candidates.push({ archivePath, metadata: null });
533
+ }
534
+ }
535
+ return candidates;
536
+ }
537
+ /** Stable reason codes of the archive identity refusals. */
538
+ export const ARCHIVE_IDENTITY_REFUSAL_CODES = [
539
+ 'archive-retry-ambiguous',
540
+ 'archive-retry-conflict',
541
+ 'archive-occupant-unverifiable',
542
+ 'archive-generation-synced',
543
+ 'archive-generation-reason',
544
+ 'archive-generation-source-absent',
545
+ 'archive-generation-no-predecessor',
546
+ 'archive-generation-ambiguous-predecessor',
547
+ 'archive-generation-pending',
548
+ 'archive-generation-path-occupied',
549
+ 'archive-synced-recordless-unbound',
550
+ 'archive-synced-recordless-ambiguous',
551
+ 'archive-retry-candidate-damaged',
552
+ ];
553
+ /**
554
+ * An archive identity refusal: fails closed before any write, with a stable
555
+ * reason code that prefixes the message and that `--json` reports as `code`.
556
+ */
557
+ export class ArchiveIdentityError extends CliError {
558
+ code;
559
+ constructor(code, message, exitCode = 1) {
560
+ super(`${code}: ${message}`, exitCode);
561
+ this.code = code;
562
+ this.name = 'ArchiveIdentityError';
563
+ }
564
+ }
565
+ /**
566
+ * Recovery for a directory that does not identify itself: most often a partial
567
+ * copy an interrupted archive left behind before pending allocations were
568
+ * written (current archives write pending metadata before the copy, so their
569
+ * interrupted runs resume instead).
570
+ */
571
+ function occupantGuidance(projectName) {
572
+ return `If this directory is a partial copy left by an interrupted archive, inspect and remove it, then retry; otherwise record reopened work in a follow-up project (for example \`oat project new ${projectName}-followup\`).`;
573
+ }
574
+ /**
575
+ * Rehashes a retry candidate's retained bytes before the retry is acted on.
576
+ * The current manifest must equal the hash recorded at archive time and, when
577
+ * the source is present, the source's manifest; otherwise the archive was
578
+ * damaged after it was written, and adopting it would let the apply remove the
579
+ * only intact copy. A pending generation allocation holds no copy yet and is
580
+ * re-copied by the apply, so it is not rehashed. Read-only.
581
+ */
582
+ async function assertRetryCandidateIntact(projectName, archivePath, metadata, sourceManifestHash) {
583
+ if (metadata?.allocation === 'pending')
584
+ return;
585
+ const actual = await computeArchiveManifestHash(archivePath);
586
+ const recordedMismatch = metadata?.manifestHash !== undefined && metadata.manifestHash !== actual;
587
+ const sourceMismatch = sourceManifestHash !== null && sourceManifestHash !== actual;
588
+ if (recordedMismatch || sourceMismatch) {
589
+ throw new ArchiveIdentityError('archive-retry-candidate-damaged', `Archive \`${archivePath}\` of \`${projectName}\` no longer matches ${recordedMismatch
590
+ ? 'the manifest recorded when it was written'
591
+ : 'the project it would replace'} (current ${actual}); refusing to adopt it as a retry before any export, copy, removal or S3 mutation. Restore the archive from its S3 snapshot (\`oat repo archive sync ${metadata?.snapshotName ?? projectName} --force\`) or from backup, then retry; the project directory is left in place.`);
592
+ }
593
+ }
594
+ function followUpGuidance(projectName) {
595
+ return `Record reopened work in a follow-up project (for example \`oat project new ${projectName}-followup\`) and archive that project instead.`;
596
+ }
597
+ /**
598
+ * The one read-only evaluator of archive retry identity, shared by the archive
599
+ * apply and its dry-run. Persisted identity is the project, its originating
600
+ * scope and the snapshot/generation recorded in `.oat-archive-source.json`,
601
+ * bound to the canonical retained-artifact manifest (and, for synced projects,
602
+ * the source ref SHA):
603
+ *
604
+ * - a persisted synced snapshot keeps its recorded identity;
605
+ * - a present source whose manifest matches exactly one same-scope archive is
606
+ * an identical retry and reuses that archive's snapshot, dated export
607
+ * identity and generation, across dates;
608
+ * - an absent source resumes the single same-scope archive;
609
+ * - any other same-scope archive, an unverifiable archive at the project's
610
+ * base path, or more than one match refuses before any export, copy,
611
+ * overwrite or S3 mutation, with follow-up-project guidance;
612
+ * - otherwise a new identity is allocated, its dated export identity
613
+ * qualified by scope when a different project's archive already holds it.
614
+ *
615
+ * It writes nothing: no file, metadata, ref or remote object.
616
+ */
617
+ export async function evaluateArchiveRetryIdentity(options, dependencies = {}) {
618
+ const generationReason = options.newGeneration
619
+ ? validateNewGenerationReason(options.newGeneration.reason)
620
+ : null;
621
+ if (generationReason !== null &&
622
+ (options.scope === 'synced' || options.persistedSnapshot)) {
623
+ throw new ArchiveIdentityError('archive-generation-synced', `--new-generation applies to shared and local projects; synced project \`${options.projectName}\` is terminal at its completed ref. ${followUpGuidance(options.projectName)}`, 1);
624
+ }
625
+ const resolveTarget = dependencies.resolveArchiveProjectTarget ?? resolveArchiveProjectTarget;
626
+ const { resolveArchiveProjectTarget: _resolver, ...targetDependencies } = dependencies;
627
+ const target = await resolveTarget({
628
+ repoRoot: options.repoRoot,
629
+ projectsRoot: options.projectsRoot,
630
+ projectName: options.projectName,
631
+ ...(options.persistedSnapshot
632
+ ? {
633
+ archiveSnapshot: options.persistedSnapshot,
634
+ archiveScope: options.scope,
635
+ }
636
+ : {}),
637
+ }, targetDependencies);
638
+ const sourcePresent = await pathExists(options.projectPath);
639
+ const manifestHash = sourcePresent
640
+ ? await computeArchiveManifestHash(options.projectPath)
641
+ : null;
642
+ const base = {
643
+ projectName: options.projectName,
644
+ scope: options.scope,
645
+ manifestHash,
646
+ sourcePresent,
647
+ target,
648
+ };
649
+ if (options.persistedSnapshot) {
650
+ let existing = null;
651
+ try {
652
+ existing = readCandidateMetadata(JSON.parse(await readFile(join(target.archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
653
+ }
654
+ catch {
655
+ existing = null;
656
+ }
657
+ const archiveExists = await pathExists(target.archivePath);
658
+ if (archiveExists && existing !== null) {
659
+ // The synced source checkout may legitimately differ from the bound
660
+ // snapshot (it is pinned by the source ref), so only the recorded
661
+ // manifest is checked here.
662
+ await assertRetryCandidateIntact(options.projectName, target.archivePath, existing, null);
663
+ }
664
+ return {
665
+ ...base,
666
+ ...identityFromMetadata(existing),
667
+ disposition: archiveExists ? 'retry' : 'new',
668
+ snapshotName: options.persistedSnapshot,
669
+ archivePath: target.archivePath,
670
+ archiveExists,
671
+ generation: existing?.generation ?? 1,
672
+ };
673
+ }
674
+ if (generationReason !== null && !sourcePresent) {
675
+ throw new ArchiveIdentityError('archive-generation-source-absent', `--new-generation needs the reopened project at \`${options.projectPath}\`; it is absent. Retry without --new-generation to resume an existing archive.`, 1);
676
+ }
677
+ const archiveBasePath = resolveCompletionArchivePath(target.archiveRepoRoot, target.archiveProjectPath);
678
+ const candidates = await readArchiveIdentityCandidates(archiveBasePath, options.projectName);
679
+ const sameIdentity = candidates.filter((candidate) => candidate.metadata?.scope === options.scope);
680
+ const matches = [];
681
+ if (sourcePresent) {
682
+ for (const candidate of sameIdentity) {
683
+ const candidateHash = candidate.metadata?.manifestHash ??
684
+ (await computeArchiveManifestHash(candidate.archivePath));
685
+ const refMatches = options.sourceRefSha === undefined ||
686
+ candidate.metadata?.sourceRefSha === undefined ||
687
+ candidate.metadata.sourceRefSha === options.sourceRefSha;
688
+ if (candidateHash === manifestHash && refMatches) {
689
+ matches.push(candidate);
690
+ }
691
+ }
692
+ }
693
+ else if (options.scope !== 'synced' || sameIdentity.length === 1) {
694
+ // An absent source resumes its latest generation. A recordless synced
695
+ // apply binds its snapshot through the completed ref before this
696
+ // evaluator runs, so an unbound synced preview with several candidates
697
+ // stays unresolved.
698
+ const latest = Math.max(0, ...sameIdentity.map(generationOf));
699
+ matches.push(...sameIdentity.filter((candidate) => generationOf(candidate) === latest));
700
+ }
701
+ if (matches.length > 1) {
702
+ throw new ArchiveIdentityError('archive-retry-ambiguous', `Project \`${options.projectName}\` (${options.scope}) matches ${matches.length} existing archives (${matches
703
+ .map((match) => `\`${match.archivePath}\``)
704
+ .join(', ')}); refusing an ambiguous archive retry before any export, copy or S3 mutation. Keep exactly one archive of this project before retrying. ${followUpGuidance(options.projectName)}`, 1);
705
+ }
706
+ if (matches.length === 1) {
707
+ // An identical retry, a source-absent resume, or a replayed
708
+ // `--new-generation` request (same predecessor and content, so the same
709
+ // generation request id) all reuse the persisted identity.
710
+ const match = matches[0];
711
+ await assertRetryCandidateIntact(options.projectName, match.archivePath, match.metadata, manifestHash);
712
+ return {
713
+ ...base,
714
+ ...identityFromMetadata(match.metadata),
715
+ disposition: 'retry',
716
+ snapshotName: match.metadata.snapshotName,
717
+ archivePath: match.archivePath,
718
+ archiveExists: true,
719
+ generation: match.metadata.generation ?? 1,
720
+ };
721
+ }
722
+ if (generationReason !== null) {
723
+ return allocateArchiveGeneration(options, base, archiveBasePath, candidates, sameIdentity, generationReason);
724
+ }
725
+ if (sourcePresent) {
726
+ // Name the latest generation, the archive the operator would build on.
727
+ const latestGeneration = Math.max(0, ...sameIdentity.map(generationOf));
728
+ const conflict = sameIdentity.find((candidate) => generationOf(candidate) === latestGeneration) ?? candidates.find((candidate) => candidate.metadata === null);
729
+ if (conflict) {
730
+ throw conflict.metadata
731
+ ? new ArchiveIdentityError('archive-retry-conflict', `Project \`${options.projectName}\` (${options.scope}) was already archived as \`${conflict.metadata.snapshotName}\` (generation ${generationOf(conflict)}${conflict.metadata.allocation === 'pending' ? ', allocation: pending' : ''}) at \`${conflict.archivePath}\`, and its retained artifacts differ from that snapshot; refusing to overwrite the archive or create a second copy before any export, copy or S3 mutation. ${followUpGuidance(options.projectName)}`)
732
+ : new ArchiveIdentityError('archive-occupant-unverifiable', `\`${conflict.archivePath}\` already exists without readable archive metadata, so it cannot be verified as an earlier archive of \`${options.projectName}\`; refusing to overwrite it or create a second copy before any export, copy or S3 mutation. ${occupantGuidance(options.projectName)}`);
733
+ }
734
+ }
735
+ // The local path and the dated export identity derive from the identity,
736
+ // never the clock: a free or empty base path is used as is; when another
737
+ // project's archive holds the base path or the dated identity, both carry
738
+ // the scope (`<project>-<scope>`, `<YYYYMMDD>-<project>-<scope>`), the name
739
+ // `oat repo archive sync` hydrates the snapshot to.
740
+ const datedIdentity = buildArchiveSnapshotName(options.projectName, options.timestamp);
741
+ const baseOccupied = (await pathExists(archiveBasePath)) &&
742
+ (await readdir(archiveBasePath)).length > 0;
743
+ const scopeQualified = baseOccupied ||
744
+ candidates.some((candidate) => candidate.metadata?.snapshotName === datedIdentity);
745
+ const localName = formatArchiveLocalName(options.projectName, 1, scopeQualified ? options.scope : null);
746
+ const snapshotName = buildArchiveSnapshotName(localName, options.timestamp);
747
+ const archivePath = scopeQualified
748
+ ? join(dirname(archiveBasePath), localName)
749
+ : archiveBasePath;
750
+ // One rule for empty directories at both paths: an empty one is free.
751
+ if (scopeQualified &&
752
+ (await pathExists(archivePath)) &&
753
+ !(await isEmptyDirectory(archivePath))) {
754
+ throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${archivePath}\` already exists and is not an archive of \`${options.projectName}\` (${options.scope}); refusing to overwrite it or create a second copy before any export, copy or S3 mutation. ${occupantGuidance(options.projectName)}`);
755
+ }
756
+ return {
757
+ ...base,
758
+ disposition: 'new',
759
+ snapshotName,
760
+ archivePath,
761
+ archiveExists: false,
762
+ generation: 1,
763
+ predecessor: null,
764
+ generationRequest: null,
765
+ generationReason: null,
766
+ allocationPending: false,
767
+ };
768
+ }
769
+ /**
770
+ * Resolves `--new-generation`: exactly one verified latest generation of the
771
+ * same project and scope is the predecessor, and the successor gets the next
772
+ * generation number, its own local path, dated export identity and generation
773
+ * request id. Nothing is written here; the apply persists the allocation
774
+ * before any side effect.
775
+ */
776
+ async function allocateArchiveGeneration(options, base, archiveBasePath, candidates, sameIdentity, generationReason) {
777
+ const unverifiable = candidates.find((candidate) => candidate.metadata === null);
778
+ if (unverifiable) {
779
+ throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${unverifiable.archivePath}\` already exists without readable archive metadata, so it cannot be a verified predecessor of \`${options.projectName}\`; refusing to allocate a generation. ${occupantGuidance(options.projectName)}`);
780
+ }
781
+ if (sameIdentity.length === 0) {
782
+ throw new ArchiveIdentityError('archive-generation-no-predecessor', `--new-generation needs an archived predecessor of \`${options.projectName}\` (${options.scope}), and none exists; archive the project without --new-generation.`, 1);
783
+ }
784
+ const latest = Math.max(...sameIdentity.map(generationOf));
785
+ const heads = sameIdentity.filter((candidate) => generationOf(candidate) === latest);
786
+ if (heads.length !== 1) {
787
+ throw new ArchiveIdentityError('archive-generation-ambiguous-predecessor', `--new-generation for \`${options.projectName}\` (${options.scope}) needs exactly one predecessor, but ${heads.length} archives hold generation ${latest} (${heads
788
+ .map((head) => `\`${head.archivePath}\``)
789
+ .join(', ')}); refusing to allocate a generation. ${followUpGuidance(options.projectName)}`, 1);
790
+ }
791
+ const predecessor = heads[0].metadata;
792
+ if (predecessor.allocation === 'pending') {
793
+ throw new ArchiveIdentityError('archive-generation-pending', `Generation ${latest} of \`${options.projectName}\` (\`${predecessor.snapshotName}\`) is still being archived; finish it by retrying with its original content before requesting another generation.`, 1);
794
+ }
795
+ const generation = latest + 1;
796
+ const datedIdentity = buildArchiveSnapshotName(formatArchiveLocalName(options.projectName, generation, null), options.timestamp);
797
+ const generationPath = join(dirname(archiveBasePath), formatArchiveLocalName(options.projectName, generation, null));
798
+ // The generation-1 rule, applied to successors: when the same project's
799
+ // archive from another scope already holds `<project>-gN` or the dated
800
+ // identity, both the local path and the export identity carry the scope
801
+ // (`<project>-gN-<scope>`, `<YYYYMMDD>-<project>-gN-<scope>`), the name
802
+ // `oat repo archive sync` hydrates the snapshot to. Anything else at the
803
+ // path still refuses.
804
+ // A family keeps its qualifier: when the predecessor's own archive name
805
+ // carries this scope (`<project>[-gN]-<scope>`), every successor does too,
806
+ // whatever order the scopes reach the next generation in, so
807
+ // `oat repo archive sync <project>-<scope>` keeps restoring its latest one.
808
+ const predecessorQualified = basename(heads[0].archivePath) ===
809
+ formatArchiveLocalName(options.projectName, latest, options.scope);
810
+ const pathHeldByOtherScope = candidates.some((candidate) => candidate.archivePath === generationPath &&
811
+ candidate.metadata !== null &&
812
+ candidate.metadata.scope !== options.scope);
813
+ const scopeQualified = predecessorQualified ||
814
+ pathHeldByOtherScope ||
815
+ candidates.some((candidate) => candidate.metadata?.snapshotName === datedIdentity);
816
+ const localName = formatArchiveLocalName(options.projectName, generation, scopeQualified ? options.scope : null);
817
+ const snapshotName = buildArchiveSnapshotName(localName, options.timestamp);
818
+ const archivePath = join(dirname(archiveBasePath), localName);
819
+ // Same rule as the base and scope-qualified paths: an empty directory is free.
820
+ if ((await pathExists(archivePath)) &&
821
+ !(await isEmptyDirectory(archivePath))) {
822
+ throw new ArchiveIdentityError('archive-generation-path-occupied', `\`${archivePath}\` already exists and is not generation ${generation} of \`${options.projectName}\` (${options.scope}); refusing to overwrite it.`, 1);
823
+ }
824
+ return {
825
+ ...base,
826
+ disposition: 'new',
827
+ snapshotName,
828
+ archivePath,
829
+ archiveExists: false,
830
+ generation,
831
+ predecessor: predecessor.snapshotName,
832
+ generationRequest: sha256(JSON.stringify([
833
+ 'oat-archive-generation/1',
834
+ options.projectName,
835
+ options.scope,
836
+ predecessor.snapshotName,
837
+ base.manifestHash,
838
+ ])),
839
+ generationReason,
840
+ allocationPending: false,
841
+ };
842
+ }
843
+ /**
844
+ * Finalizes the export-safe source locators of a summary bound for the tracked
845
+ * export: `oat-project:<slug>#<entry-id>` tokens of this project become
846
+ * `oat-archive:<snapshot>#<entry-id>`, and a relative Markdown link to the
847
+ * archived-only project log or legacy learnings file becomes plain text naming
848
+ * the archive identity; a relative link to unarchived review or PR evidence
849
+ * (`reviews/…`, `pr/…`) becomes plain provenance naming the file. Every other
850
+ * byte is unchanged.
851
+ */
852
+ export function finalizeSummaryArchiveLocators(text, projectName, snapshotName) {
853
+ const slug = projectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
854
+ return text
855
+ .replace(new RegExp(`${PROJECT_LOG_LOCATOR_PREFIX}${slug}#([a-z0-9][a-z0-9-]*)`, 'g'), (_token, entryId) => `${ARCHIVE_LOCATOR_PREFIX}${snapshotName}#${entryId}`)
856
+ .replace(/\[([^\]\n]+)\]\((?:\.\/)?(project-log\.md|oat-execution-learnings\.md)(?:#[^)\s]*)?\)/g, (_link, label, file) => `${label} (${ARCHIVE_LOCATOR_PREFIX}${snapshotName}/${file})`)
857
+ .replace(
858
+ // Review and PR evidence are never archived (D2), so a local link to
859
+ // either cannot resolve from the export: keep its label and file name as
860
+ // plain provenance. Absolute and external URLs do not match.
861
+ /\[([^\]\n]+)\]\((?:\.\/)?((reviews|pr)\/[^)\s#]+)(?:#[^)\s]*)?\)/g, (_link, label, file, kind) => `${label} (${kind === 'pr' ? 'PR artifact' : 'review'} \`${file}\`, local to the project; not archived)`);
862
+ }
340
863
  async function writeArchiveSnapshotMetadata(archivePath, metadata) {
341
864
  await writeFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
342
865
  }
@@ -356,7 +879,13 @@ async function verifyArchiveSnapshotMetadata(archivePath, expected) {
356
879
  throw new CliError(`Existing archive \`${archivePath}\` does not match persisted snapshot \`${expected.snapshotName}\`; refusing to overwrite it.`);
357
880
  }
358
881
  }
359
- async function resolveRecordlessSyncedArchiveIdentity(options, target, git, dependencies) {
882
+ /**
883
+ * Binds a recordless synced project's archive identity through its
884
+ * authoritative completed ref (read-only: it scans local archive metadata and
885
+ * probes refs). Apply and dry-run both use it, so a preview reports the
886
+ * snapshot the apply would bind, or refuses as the apply would.
887
+ */
888
+ export async function resolveRecordlessSyncedArchiveIdentity(options, target, git, dependencies) {
360
889
  const tentativeTarget = await resolveArchiveProjectTarget(options, dependencies);
361
890
  assertDurableArchiveProjectTarget(tentativeTarget);
362
891
  const archiveRoot = dirname(tentativeTarget.archivePath);
@@ -397,7 +926,7 @@ async function resolveRecordlessSyncedArchiveIdentity(options, target, git, depe
397
926
  }
398
927
  }
399
928
  if (candidates.length === 0) {
400
- throw new CliError(`Synced project ${options.projectName} has no active record and no persisted synced archive identity; refusing to create a replacement active record or a new snapshot.`, 2);
929
+ throw new ArchiveIdentityError('archive-synced-recordless-unbound', `Synced project ${options.projectName} has no active record and no persisted synced archive identity; refusing to create a replacement active record or a new snapshot.`, 2);
401
930
  }
402
931
  const probe = dependencies.probeSyncedTerminalRefs ?? probeSyncedTerminalRefs;
403
932
  const matches = [];
@@ -410,13 +939,13 @@ async function resolveRecordlessSyncedArchiveIdentity(options, target, git, depe
410
939
  }
411
940
  }
412
941
  if (matches.length !== 1) {
413
- throw new CliError(matches.length === 0
414
- ? `Synced project ${options.projectName} has no active record, but no persisted archive identity matches its authoritative completed ref; refusing terminal retry before export or S3 mutation.`
415
- : `Synced project ${options.projectName} has no active record and multiple persisted archives match its authoritative completed ref; refusing an ambiguous terminal retry.`, 2);
942
+ throw matches.length === 0
943
+ ? new ArchiveIdentityError('archive-synced-recordless-unbound', `Synced project ${options.projectName} has no active record, but no persisted archive identity matches its authoritative completed ref; refusing terminal retry before export or S3 mutation.`, 2)
944
+ : new ArchiveIdentityError('archive-synced-recordless-ambiguous', `Synced project ${options.projectName} has no active record and multiple persisted archives match its authoritative completed ref; refusing an ambiguous terminal retry.`, 2);
416
945
  }
417
946
  return matches[0];
418
947
  }
419
- async function exportProjectSummary(archivePath, snapshotName, summaryExportPath, repoRoot, dependencies, recapExport) {
948
+ async function exportProjectSummary(archivePath, projectName, snapshotName, summaryExportPath, repoRoot, dependencies, recapExport) {
420
949
  const summarySource = join(archivePath, 'summary.md');
421
950
  const exists = dependencies.fileExists ?? fileExists;
422
951
  if (!(await exists(summarySource))) {
@@ -425,13 +954,16 @@ async function exportProjectSummary(archivePath, snapshotName, summaryExportPath
425
954
  const summaryTarget = join(repoRoot, summaryExportPath, `${snapshotName}.md`);
426
955
  const copySummary = dependencies.copySingleFile ?? copySingleFile;
427
956
  const sourceContents = await readFile(summarySource);
428
- let expectedContents = sourceContents;
957
+ const sourceText = sourceContents.toString('utf8');
958
+ const finalizedText = finalizeSummaryArchiveLocators(sourceText, projectName, snapshotName);
959
+ // Untouched bytes are exported as copied; only a finalized locator rewrites.
960
+ let expectedContents = finalizedText === sourceText ? sourceContents : Buffer.from(finalizedText);
429
961
  if (recapExport) {
430
962
  const pageLink = relative(dirname(summaryTarget), recapExport.exportRoot)
431
963
  .split(sep)
432
964
  .join('/');
433
965
  const receipt = `Recap: [View the recap](${pageLink})\n\nRun: \`${recapExport.runId}\` \nOriginal page SHA-256: \`${recapExport.page.originalSha256}\` \nExported page SHA-256: \`${recapExport.page.exportedSha256}\`\n`;
434
- const text = sourceContents.toString('utf8');
966
+ const text = finalizedText;
435
967
  const outcome = /(^## Explainer Outcome[^\n]*\n)([\s\S]*?)(?=^## |$(?![\s\S]))/m;
436
968
  const rewriteLinks = (body) => body.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (markdownLink, label, href) => /(?:explainers\/|project-recaps\/)/.test(href)
437
969
  ? `[${label}](${pageLink})`
@@ -448,8 +980,9 @@ async function exportProjectSummary(archivePath, snapshotName, summaryExportPath
448
980
  return summaryTarget;
449
981
  }
450
982
  await copySummary(summarySource, summaryTarget);
451
- if (recapExport)
983
+ if (!expectedContents.equals(sourceContents)) {
452
984
  await writeFile(summaryTarget, expectedContents);
985
+ }
453
986
  if (!expectedContents.equals(await readFile(summaryTarget))) {
454
987
  throw new CliError(`Summary export \`${summaryTarget}\` failed byte verification.`);
455
988
  }
@@ -1155,7 +1688,6 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1155
1688
  const ensureAccess = dependencies.ensureS3ArchiveAccess ?? ensureS3ArchiveAccess;
1156
1689
  const execFile = dependencies.execFile ?? execFileAsync;
1157
1690
  const timestamp = dependencies.timestamp?.() ?? new Date().toISOString();
1158
- const snapshotName = buildArchiveSnapshotName(options.projectName, timestamp);
1159
1691
  const syncedRoot = resolveScopeRoot(options.repoRoot, options.projectsRoot, 'synced');
1160
1692
  const recordPath = syncedRecordPath(syncedRoot, options.projectName);
1161
1693
  const readRecord = dependencies.readSyncedRecord ?? readSyncedRecord;
@@ -1206,19 +1738,24 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1206
1738
  throw new CliError(`Archive retry for ${options.projectName} is bound to source ref ${activeRecord.archiveSourceRefSha}, but ${syncTarget.ref} is now ${sourceRefSha}. Restore the retained ref to the recorded commit to finish this archive transaction, or preserve the existing snapshot and start a separately reviewed recovery; refusing to accept stale archive content.`, 1);
1207
1739
  }
1208
1740
  }
1209
- const archiveTarget = await resolveArchiveProjectTarget({
1741
+ const persistedSnapshot = activeRecord?.archiveSnapshot ?? recordlessIdentity?.metadata.snapshotName;
1742
+ // The shared read-only evaluator owns retry identity for apply and dry-run:
1743
+ // an identical retry reuses its snapshot, a conflicting or ambiguous one
1744
+ // refuses here, before any export, copy, overwrite or S3 mutation.
1745
+ const identity = await evaluateArchiveRetryIdentity({
1210
1746
  repoRoot: options.repoRoot,
1211
1747
  projectsRoot: options.projectsRoot,
1212
1748
  projectName: options.projectName,
1213
- ...((activeRecord?.archiveSnapshot ??
1214
- recordlessIdentity?.metadata.snapshotName)
1215
- ? {
1216
- archiveSnapshot: activeRecord?.archiveSnapshot ??
1217
- recordlessIdentity?.metadata.snapshotName,
1218
- archiveScope: projectScope,
1219
- }
1749
+ projectPath: options.projectPath,
1750
+ scope: projectScope,
1751
+ timestamp,
1752
+ ...(persistedSnapshot ? { persistedSnapshot } : {}),
1753
+ ...(sourceRefSha ? { sourceRefSha } : {}),
1754
+ ...(options.newGenerationReason != null
1755
+ ? { newGeneration: { reason: options.newGenerationReason } }
1220
1756
  : {}),
1221
1757
  }, dependencies);
1758
+ const archiveTarget = identity.target;
1222
1759
  assertDurableArchiveProjectTarget(archiveTarget);
1223
1760
  // Reassert the identity immediately before the first durable mutation. The
1224
1761
  // archive transaction must never bind a descendant source to a sibling
@@ -1231,12 +1768,8 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1231
1768
  canonicalProjectPath !== canonicalizePath(syncTarget.projectPath)) {
1232
1769
  throw new CliError(`Synced archive identity for ${options.projectName} does not match its canonical checkout, record, and ref.`, 1);
1233
1770
  }
1234
- const archivePath = archiveTarget.archivePath;
1235
- const exportIdentity = syncTarget
1236
- ? (activeRecord?.archiveSnapshot ??
1237
- recordlessIdentity?.metadata.snapshotName ??
1238
- snapshotName)
1239
- : snapshotName;
1771
+ const archivePath = identity.archivePath;
1772
+ const exportIdentity = identity.snapshotName;
1240
1773
  const snapshotId = syncTarget ? exportIdentity : basename(archivePath);
1241
1774
  const shouldPersistArchiveSnapshot = Boolean(syncTarget &&
1242
1775
  activeRecord &&
@@ -1249,7 +1782,47 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1249
1782
  };
1250
1783
  await writeRecord(recordPath, activeRecord);
1251
1784
  }
1252
- const archiveExists = await (dependencies.dirExists ?? dirExists)(archivePath);
1785
+ const generationMetadata = identity.predecessor
1786
+ ? {
1787
+ predecessor: identity.predecessor,
1788
+ ...(identity.generationRequest
1789
+ ? { generationRequest: identity.generationRequest }
1790
+ : {}),
1791
+ ...(identity.generationReason
1792
+ ? { generationReason: identity.generationReason }
1793
+ : {}),
1794
+ }
1795
+ : {};
1796
+ let allocationPending = identity.allocationPending;
1797
+ // What this in-process call created, so a failed copy removes nothing else.
1798
+ // OAT does not serialize concurrent archive runs of one project.
1799
+ let createdAllocation = false;
1800
+ let createdDirectory = false;
1801
+ if (identity.disposition === 'new') {
1802
+ // Persist the new archive (a first archive or a successor generation, with
1803
+ // its predecessor binding) before any export, copy or S3 side effect, so
1804
+ // an interrupted run resumes this identity instead of allocating from the
1805
+ // current date. A stray occupant at the path refuses and is not touched.
1806
+ createdDirectory = !(await pathExists(archivePath));
1807
+ await makeDir(archivePath);
1808
+ await refuseForeignOccupant(archivePath, options.projectName, projectScope);
1809
+ await writeArchiveSnapshotMetadata(archivePath, {
1810
+ projectName: options.projectName,
1811
+ snapshotName: exportIdentity,
1812
+ scope: projectScope,
1813
+ ...(sourceRefSha ? { sourceRefSha } : {}),
1814
+ ...(identity.manifestHash ? { manifestHash: identity.manifestHash } : {}),
1815
+ generation: identity.generation,
1816
+ ...generationMetadata,
1817
+ allocation: 'pending',
1818
+ });
1819
+ allocationPending = true;
1820
+ createdAllocation = true;
1821
+ }
1822
+ // An empty directory at the identity's path holds no archive; the copy
1823
+ // fills it in place.
1824
+ const archiveExists = (await (dependencies.dirExists ?? dirExists)(archivePath)) &&
1825
+ !(await isEmptyDirectory(archivePath));
1253
1826
  if (archiveExists) {
1254
1827
  await verifyArchiveSnapshotMetadata(archivePath, {
1255
1828
  projectName: options.projectName,
@@ -1263,20 +1836,56 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1263
1836
  : archivePath;
1264
1837
  let attemptedProjectRecapExport = await exportSelectedProjectRecap({ ...options, projectPath: projectSourcePath }, exportIdentity, dependencies);
1265
1838
  let projectRecapExport = attemptedProjectRecapExport?.export ?? null;
1266
- if (!archiveExists) {
1839
+ if (!archiveExists || allocationPending) {
1267
1840
  try {
1268
1841
  await makeDir(dirname(archivePath));
1269
- await copyProjectDirectory(options.projectPath, archivePath, (_sourcePath, relativePath) => relativePath !== 'reviews' &&
1270
- (!syncTarget || relativePath !== '.git'));
1842
+ // A resumed pending allocation (an interrupted earlier run) holds a
1843
+ // partial copy of this identity: clear it, never a completed archive.
1844
+ if (allocationPending &&
1845
+ !createdAllocation &&
1846
+ !(await clearPendingGeneration(archivePath, removePath))) {
1847
+ throw new ArchiveIdentityError('archive-retry-conflict', `Archive \`${archivePath}\` of \`${options.projectName}\` is no longer a pending allocation; refusing to clear or overwrite it before any copy or S3 mutation. Retry: an identical retry reuses the completed archive.`);
1848
+ }
1849
+ await copyProjectDirectory(options.projectPath, archivePath,
1850
+ // Archive-owned entries are never copied from the source: a source
1851
+ // reopened by copying its archive back would otherwise overwrite this
1852
+ // generation's pending allocation with its predecessor's identity.
1853
+ (_sourcePath, relativePath) => !ARCHIVE_RETENTION_EXCLUSIONS.includes(relativePath) &&
1854
+ !ARCHIVE_OWNED_ENTRIES.includes(relativePath));
1271
1855
  await writeArchiveSnapshotMetadata(archivePath, {
1272
1856
  projectName: options.projectName,
1273
1857
  snapshotName: exportIdentity,
1274
1858
  scope: projectScope,
1275
1859
  ...(sourceRefSha ? { sourceRefSha } : {}),
1860
+ manifestHash: await computeArchiveManifestHash(archivePath),
1861
+ generation: identity.generation,
1862
+ ...generationMetadata,
1276
1863
  });
1277
1864
  }
1278
1865
  catch (error) {
1279
- await removePath(archivePath, { recursive: true, force: true });
1866
+ // Only a still-pending allocation is cleaned up; an archive whose
1867
+ // metadata is final is never removed or cleared.
1868
+ const stillPending = (await readArchiveMetadataAt(archivePath))?.allocation === 'pending';
1869
+ if (stillPending && createdAllocation && identity.generation === 1) {
1870
+ // A first archive this call created is removed whole, so a retry
1871
+ // starts from one clean target.
1872
+ if (createdDirectory) {
1873
+ await removePath(archivePath, { recursive: true, force: true });
1874
+ }
1875
+ else {
1876
+ for (const entry of await readdir(archivePath)) {
1877
+ await removePath(join(archivePath, entry), {
1878
+ recursive: true,
1879
+ force: true,
1880
+ });
1881
+ }
1882
+ }
1883
+ }
1884
+ else if (stillPending) {
1885
+ // A successor, or any resumed allocation, keeps its pending metadata
1886
+ // and drops the partial copy, so the retry resumes it.
1887
+ await clearPendingGeneration(archivePath, removePath);
1888
+ }
1280
1889
  await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
1281
1890
  throw error;
1282
1891
  }
@@ -1299,10 +1908,13 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1299
1908
  throw error;
1300
1909
  }
1301
1910
  const warnings = [];
1911
+ const replayWarning = newGenerationReplayWarning(identity, options.newGenerationReason);
1912
+ if (replayWarning)
1913
+ warnings.push(replayWarning);
1302
1914
  let summaryExportFile = null;
1303
1915
  if (options.summaryExportPath) {
1304
1916
  try {
1305
- summaryExportFile = await exportProjectSummary(archivePath, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies, projectRecapExport);
1917
+ summaryExportFile = await exportProjectSummary(archivePath, options.projectName, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies, projectRecapExport);
1306
1918
  }
1307
1919
  catch (error) {
1308
1920
  const message = error instanceof Error ? error.message : String(error);
@@ -1433,8 +2045,60 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
1433
2045
  snapshotId,
1434
2046
  terminalReceipt,
1435
2047
  recordRetired,
2048
+ generation: identity.generation,
2049
+ predecessorSnapshot: identity.predecessor,
1436
2050
  };
1437
2051
  }
2052
+ async function isEmptyDirectory(target) {
2053
+ try {
2054
+ return (await readdir(target)).length === 0;
2055
+ }
2056
+ catch {
2057
+ return false;
2058
+ }
2059
+ }
2060
+ async function readArchiveMetadataAt(archivePath) {
2061
+ try {
2062
+ return readCandidateMetadata(JSON.parse(await readFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
2063
+ }
2064
+ catch {
2065
+ return null;
2066
+ }
2067
+ }
2068
+ /**
2069
+ * Before a new archive's metadata is written: anything already at the path
2070
+ * (an older CLI mid-copy, a person or a tool) does not identify itself as this
2071
+ * archive, so the run refuses and leaves it untouched.
2072
+ */
2073
+ async function refuseForeignOccupant(archivePath, projectName, scope) {
2074
+ const occupants = await readdir(archivePath);
2075
+ if (occupants.length === 0) {
2076
+ return;
2077
+ }
2078
+ throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${archivePath}\` gained content that does not identify itself as an archive of \`${projectName}\` (${scope}) after this run evaluated it (${occupants
2079
+ .sort()
2080
+ .map((entry) => `\`${entry}\``)
2081
+ .join(', ')}); refusing to overwrite or clear it before any export, copy or S3 mutation. ${occupantGuidance(projectName)}`);
2082
+ }
2083
+ /**
2084
+ * Removes everything but the allocation metadata from a generation that is
2085
+ * still a pending allocation, and reports whether it did. An archive whose
2086
+ * metadata is final is never cleared.
2087
+ */
2088
+ async function clearPendingGeneration(archivePath, removePath) {
2089
+ if ((await readArchiveMetadataAt(archivePath))?.allocation !== 'pending') {
2090
+ return false;
2091
+ }
2092
+ for (const entry of await readdir(archivePath)) {
2093
+ if (entry === ARCHIVE_SNAPSHOT_METADATA_FILENAME)
2094
+ continue;
2095
+ await removePath(join(archivePath, entry), {
2096
+ recursive: true,
2097
+ force: true,
2098
+ });
2099
+ }
2100
+ return true;
2101
+ }
1438
2102
  async function recoverSyncedLifecycleCommit(repoRoot, pathspecs, projectName, expectedSubject, git) {
1439
2103
  const [recordPathspec] = pathspecs;
1440
2104
  if (!recordPathspec) {