verify-archived-agent-notes.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /** Verify and append-seal the frozen Agent Note archive. */
  2. import { spawnSync } from 'node:child_process'
  3. import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts'
  6. import {
  7. extendArchiveManifest,
  8. parseArchiveManifest,
  9. renderArchiveManifest,
  10. validateArchiveArtifacts,
  11. validateArchiveManifestExtension,
  12. type ArchiveManifest,
  13. } from './archived-agent-notes.ts'
  14. const args = process.argv.slice(2)
  15. const writeMode = args.length === 1 && args[0] === '--write'
  16. if (args.length > 0 && !writeMode) {
  17. console.error('verify-archived-agent-notes: usage: tsx scripts/verify-archived-agent-notes.ts [--write]')
  18. process.exit(1)
  19. }
  20. const archiveRoot = resolve(agentNoteRoot, 'archived')
  21. const manifestPath = resolve(archiveRoot, 'manifest.json')
  22. const repoRoot = resolve(agentNoteRoot, '../..')
  23. const manifestRepoPath = '.agents/notes/archived/manifest.json'
  24. const errors: string[] = []
  25. const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json'])
  26. const kinds = new Set<string>()
  27. if (!existsSync(resolve(archiveRoot, 'AGENTS.md'))) errors.push('archived/AGENTS.md is required')
  28. const artifacts = new Map<string, Buffer>()
  29. for (const entry of readdirSync(archiveRoot, { withFileTypes: true })) {
  30. if (entry.isFile()) {
  31. if (!allowedRootFiles.has(entry.name)) errors.push(`archived/${entry.name}: unexpected root file`)
  32. continue
  33. }
  34. if (!entry.isDirectory()) {
  35. errors.push(`archived/${entry.name}: only regular files and kind directories are allowed`)
  36. continue
  37. }
  38. if (!(AGENT_NOTE_CLASSES as readonly string[]).includes(entry.name)) {
  39. errors.push(`archived/${entry.name}/: unknown Agent Note kind`)
  40. continue
  41. }
  42. kinds.add(entry.name)
  43. for (const child of readdirSync(resolve(archiveRoot, entry.name), { withFileTypes: true })) {
  44. const rel = `${entry.name}/${child.name}`
  45. if (!child.isFile()) {
  46. errors.push(`${rel}: archived kind directories contain regular files only`)
  47. continue
  48. }
  49. artifacts.set(rel, readFileSync(resolve(archiveRoot, rel)))
  50. }
  51. }
  52. for (const kind of AGENT_NOTE_CLASSES) {
  53. if (!kinds.has(kind)) errors.push(`archived/${kind}/: required kind directory is missing`)
  54. }
  55. errors.push(...validateArchiveArtifacts(artifacts))
  56. function runGit(args: string[]): string {
  57. const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' })
  58. if (result.error !== undefined) throw result.error
  59. if (result.status !== 0) throw new Error(result.stderr.trim() || `git exited with status ${result.status}`)
  60. return result.stdout
  61. }
  62. function readBaselineManifest(ref: string): ArchiveManifest {
  63. runGit(['cat-file', '-e', `${ref}^{commit}`])
  64. const manifestEntry = runGit(['ls-tree', '--name-only', ref, '--', manifestRepoPath]).trim()
  65. if (manifestEntry === '') return { version: 1, files: {} }
  66. return parseArchiveManifest(runGit(['show', `${ref}:${manifestRepoPath}`]))
  67. }
  68. let manifest: ArchiveManifest = { version: 1, files: {} }
  69. if (existsSync(manifestPath)) {
  70. try {
  71. manifest = parseArchiveManifest(readFileSync(manifestPath, 'utf8'))
  72. } catch (error: unknown) {
  73. errors.push(`archived/manifest.json: ${error instanceof Error ? error.message : String(error)}`)
  74. }
  75. } else if (!writeMode) {
  76. errors.push('archived/manifest.json is required; seal new artifacts with `pnpm run verify-archived-agent-notes --write`')
  77. }
  78. // CI supplies its trusted pre-change commit; local writes compare with committed HEAD.
  79. const baselineRef = process.env.DSH_ARCHIVE_BASE_REF ?? 'HEAD'
  80. try {
  81. const baseline = readBaselineManifest(baselineRef)
  82. errors.push(...validateArchiveManifestExtension(baseline, manifest))
  83. } catch (error: unknown) {
  84. errors.push(`archived/manifest.json: cannot read baseline ${JSON.stringify(baselineRef)}: ${error instanceof Error ? error.message : String(error)}`)
  85. }
  86. const extended = extendArchiveManifest(manifest, artifacts)
  87. errors.push(...extended.errors)
  88. if (!writeMode) {
  89. for (const path of extended.added) errors.push(`${path}: archived artifact is not sealed in manifest.json`)
  90. }
  91. if (errors.length > 0) {
  92. console.error('verify-archived-agent-notes: archive rules violated:')
  93. for (const error of errors) console.error(` ${error}`)
  94. process.exit(1)
  95. }
  96. if (writeMode) {
  97. const rendered = renderArchiveManifest(extended.files)
  98. if (!existsSync(manifestPath) || readFileSync(manifestPath, 'utf8') !== rendered) {
  99. writeFileSync(manifestPath, rendered)
  100. }
  101. console.log(`verify-archived-agent-notes: sealed ${extended.added.length} new artifact(s); existing seals unchanged.`)
  102. } else {
  103. console.log(`verify-archived-agent-notes: ${artifacts.size} frozen artifact(s) checked across ${kinds.size} kind(s).`)
  104. }