2026-07-05 22:58:25 +08:00
/**
2026-07-19 22:50:49 +08:00
* Enforce Agent Note headers, lifecycle-specific sections, alternatives, and retired
2026-07-12 03:36:43 +08:00
* marker rules. Classification and filenames belong to the sibling tree gate;
2026-07-13 23:27:00 +08:00
* translation structure belongs to the pairing gate. Exact format and
2026-07-19 22:50:49 +08:00
* grandfathering rules live in `.agents/notes/README.md`.
2026-07-05 22:58:25 +08:00
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
2026-07-19 23:15:23 +08:00
import { agentNoteRoot , walkAgentNoteTree } from './agent-note-tree.ts'
2026-07-05 22:58:25 +08:00
2026-08-09 15:27:21 +08:00
/** The date these format rules took effect; the grandfather comment is valid only before it. */
2026-07-05 22:58:25 +08:00
const FORMAT_ADOPTED = '2026-07-05'
2026-07-19 22:50:49 +08:00
/** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */
const GRANDFATHER = '<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->'
2026-07-05 22:58:25 +08:00
/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
2026-07-19 22:50:49 +08:00
const LEGACY_MARKERS = [ 'XXX: legacy ADR/RFC body format' , 'XXX: legacy ADR/Agent Note body format' ]
2026-07-05 22:58:25 +08:00
/** Status-line grammar per lifecycle folder. */
const STATUS : Record < string , RegExp > = {
proposed : /^Status: proposed$/ ,
implemented : /^Status: implemented$/ ,
rejected : /^Status: rejected — .+$/ ,
}
/** Required `##` headings per lifecycle, beyond the universal `## Problem` opener. */
const REQUIRED : Record < string , string [ ] > = {
proposed : [ '## Proposal' , '## Acceptance criteria' , '## Risks' ] ,
implemented : [ '## Decision' , '## Consequences' ] ,
rejected : [ '## Proposal' ] ,
}
/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
2026-07-19 22:50:49 +08:00
const { notes , errors } = walkAgentNoteTree ( )
2026-07-05 22:58:25 +08:00
2026-07-19 22:50:49 +08:00
for ( const note of notes ) {
2026-07-05 22:58:25 +08:00
const fail = ( msg : string ) : void = > {
2026-07-19 22:50:49 +08:00
errors . push ( ` format: ${ note . rel } — ${ msg } ` )
2026-07-05 22:58:25 +08:00
}
2026-07-19 22:50:49 +08:00
const lines = readFileSync ( resolve ( agentNoteRoot , note . rel ) , 'utf8' ) . split ( '\n' )
2026-07-12 03:36:43 +08:00
// Format tokens inside fenced examples are not document structure.
2026-07-05 23:45:34 +08:00
let inFence = false
const prose = lines . filter ( ( l ) = > {
if ( l . startsWith ( '```' ) ) {
inFence = ! inFence
return false
}
return ! inFence
} )
2026-07-05 22:58:25 +08:00
2026-07-19 22:50:49 +08:00
if ( ! /^# Agent Note: \S/ . test ( lines [ 0 ] ? ? '' ) ) fail ( 'line 1 must be `# Agent Note: <title>`' )
2026-07-05 22:58:25 +08:00
if ( lines [ 1 ] !== '' ) fail ( 'line 2 must be blank' )
2026-07-19 22:50:49 +08:00
const status = STATUS [ note . lifecycle ]
2026-07-05 22:58:25 +08:00
if ( status !== undefined && ! status . test ( lines [ 2 ] ? ? '' ) ) {
2026-07-19 22:50:49 +08:00
fail ( ` line 3 must match the ${ note . lifecycle } status grammar ( ${ String ( status ) } ) ` )
2026-07-05 22:58:25 +08:00
}
if ( lines [ 3 ] !== '' ) fail ( 'line 4 must be blank' )
2026-07-05 23:45:34 +08:00
const statusLines = prose . filter ( l = > l . startsWith ( 'Status:' ) && l !== lines [ 2 ] )
if ( statusLines . length > 0 || prose . filter ( l = > l === lines [ 2 ] ) . length > 1 ) {
fail ( 'the line-3 `Status:` line must be the only one in the file' )
}
2026-07-05 22:58:25 +08:00
2026-07-05 23:45:34 +08:00
const h2s = prose . filter ( l = > l . startsWith ( '## ' ) ) . map ( l = > l . trimEnd ( ) )
2026-07-05 22:58:25 +08:00
if ( h2s [ 0 ] !== '## Problem' ) fail ( ` the first section must be \` ## Problem \` (got ${ JSON . stringify ( h2s [ 0 ] ? ? '<none>' ) } ) ` )
2026-07-19 22:50:49 +08:00
for ( const required of REQUIRED [ note . lifecycle ] ? ? [ ] ) {
2026-07-05 22:58:25 +08:00
if ( ! h2s . includes ( required ) ) fail ( ` missing the required \` ${ required } \` section ` )
}
2026-07-19 22:50:49 +08:00
if ( note . lifecycle === 'implemented' ) {
2026-07-05 22:58:25 +08:00
for ( const h2 of h2s . filter ( h = > BANNED_IMPLEMENTED . test ( h ) ) ) {
2026-07-19 22:50:49 +08:00
fail ( ` \` ${ h2 } \` is a proposal-era heading; an implemented Agent Note states what is (fold it into Decision/Consequences/Testing) ` )
2026-07-05 22:58:25 +08:00
}
}
const hasSection = h2s . includes ( '## Alternatives considered' )
2026-07-05 23:45:34 +08:00
const hasGrandfather = prose . includes ( GRANDFATHER )
2026-07-05 22:58:25 +08:00
if ( hasSection && hasGrandfather ) fail ( 'carries both `## Alternatives considered` and the grandfather comment — drop the comment' )
2026-07-19 22:50:49 +08:00
if ( ! hasSection && ! hasGrandfather ) fail ( 'missing `## Alternatives considered` (a pre-format Agent Note whose alternatives are not reconstructible carries the grandfather comment instead — see .agents/notes/README.md § The file format)' )
if ( hasGrandfather && note . date >= FORMAT_ADOPTED ) fail ( ` the grandfather comment is only valid for Agent Notes dated before ${ FORMAT_ADOPTED } ` )
2026-07-05 22:58:25 +08:00
2026-07-19 22:50:49 +08:00
if ( prose . some ( line = > LEGACY_MARKERS . some ( marker = > line . includes ( marker ) ) ) ) fail ( 'carries the retired legacy-format debt marker' )
2026-07-05 22:58:25 +08:00
}
if ( errors . length === 0 ) {
2026-07-19 22:50:49 +08:00
console . log ( ` verify-agent-note-format: ${ notes . length } Agent Note(s) checked, all conform to .agents/notes/README.md § The file format. ` )
2026-07-05 22:58:25 +08:00
process . exit ( 0 )
}
2026-07-19 22:50:49 +08:00
console . error ( 'verify-agent-note-format: violations found:' )
2026-07-05 22:58:25 +08:00
for ( const e of errors ) console . error ( ` ${ e } ` )
process . exit ( 1 )