@@ -46,13 +46,28 @@ const REQUIRED_FRONTMATTER_KEYS = Object.freeze([
4646 "created" ,
4747 "tags" ,
4848] ) ;
49+ const ALLOWED_FRONTMATTER_KEYS = new Set ( [
50+ ...REQUIRED_FRONTMATTER_KEYS ,
51+ "expires" ,
52+ "metadata" ,
53+ "source" ,
54+ "related" ,
55+ ] ) ;
56+ const SLUG_PATTERN = / ^ [ a - z 0 - 9 _ ] [ a - z 0 - 9 _ - ] * $ / ;
4957
5058// ─── Default path resolution ──────────────────────────────────────────────────
5159
5260function defaultMemoryRoot ( projectRoot ) {
5361 return path . join ( projectRoot , ".agent" , "memory" ) ;
5462}
5563
64+ function resolveMemoryPath ( memoryRoot , relativePath ) {
65+ const root = path . resolve ( memoryRoot ) ;
66+ const resolved = path . resolve ( root , relativePath ) ;
67+ if ( resolved !== root && ! resolved . startsWith ( root + path . sep ) ) return null ;
68+ return resolved ;
69+ }
70+
5671// ─── MEMORY.md parser (V-1 / V-2 / V-5 sources) ──────────────────────────────
5772
5873function parseMemoryIndex ( memoryRoot ) {
@@ -114,11 +129,19 @@ function parseFrontmatter(text) {
114129 if ( ! m ) return null ;
115130 const result = { } ;
116131 const lines = m [ 1 ] . split ( / \r ? \n / ) ;
132+ let currentKey = null ;
117133 for ( const line of lines ) {
118134 if ( ! line . trim ( ) || line . trim ( ) . startsWith ( "#" ) ) continue ;
135+ const listItem = line . match ( / ^ \s + - \s + ( .+ ) $ / ) ;
136+ if ( listItem && currentKey ) {
137+ if ( ! Array . isArray ( result [ currentKey ] ) ) result [ currentKey ] = [ ] ;
138+ result [ currentKey ] . push ( listItem [ 1 ] . replace ( / ^ [ " ' ] | [ " ' ] $ / g, "" ) . trim ( ) ) ;
139+ continue ;
140+ }
119141 const kv = line . match ( / ^ ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) \s * : \s * ( .* ) $ / ) ;
120142 if ( ! kv ) continue ;
121143 const key = kv [ 1 ] ;
144+ currentKey = key ;
122145 let raw = kv [ 2 ] ;
123146 // Strip trailing inline comment after a quoted value.
124147 if (
@@ -128,14 +151,66 @@ function parseFrontmatter(text) {
128151 raw = raw . slice ( 1 , - 1 ) ;
129152 } else if ( raw . startsWith ( "[" ) && raw . endsWith ( "]" ) ) {
130153 raw = raw . slice ( 1 , - 1 ) . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
131- } else if ( raw === "true" || raw === "false" || raw === "null" || raw === "" ) {
132- // Keep as-is.
154+ } else if ( raw === "" ) {
155+ raw = key === "metadata" ? { } : [ ] ;
156+ } else if ( raw === "true" || raw === "false" || raw === "null" ) {
157+ // Keep simple literals as strings; the schema validator rejects wrong types.
133158 }
134159 result [ key ] = raw ;
135160 }
136161 return result ;
137162}
138163
164+ function isValidDate ( value ) {
165+ if ( typeof value !== "string" || ! / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( value ) ) return false ;
166+ const [ year , month , day ] = value . split ( "-" ) . map ( Number ) ;
167+ const date = new Date ( Date . UTC ( year , month - 1 , day ) ) ;
168+ return date . getUTCFullYear ( ) === year && date . getUTCMonth ( ) === month - 1 && date . getUTCDate ( ) === day ;
169+ }
170+
171+ function validateFrontmatterValues ( frontmatter , expectedType ) {
172+ const errors = [ ] ;
173+ for ( const key of Object . keys ( frontmatter ) ) {
174+ if ( ! ALLOWED_FRONTMATTER_KEYS . has ( key ) ) errors . push ( `unknown frontmatter key: ${ key } ` ) ;
175+ }
176+ if ( typeof frontmatter . name !== "string" || ! SLUG_PATTERN . test ( frontmatter . name ) ) {
177+ errors . push ( "name must match ^[a-z0-9_][a-z0-9_-]*$" ) ;
178+ } else if ( frontmatter . name . length > 64 ) {
179+ errors . push ( "name must be at most 64 characters" ) ;
180+ }
181+ if ( typeof frontmatter . description !== "string" ) {
182+ errors . push ( "description must be a string" ) ;
183+ } else if ( frontmatter . description . length > 200 ) {
184+ errors . push ( "description must be at most 200 characters" ) ;
185+ }
186+ if ( ! ALL_TYPES . includes ( frontmatter . type ) ) {
187+ errors . push ( `type must be one of: ${ ALL_TYPES . join ( ", " ) } ` ) ;
188+ } else if ( frontmatter . type !== expectedType ) {
189+ errors . push ( `type must match directory "${ expectedType } "` ) ;
190+ }
191+ if ( ! isValidDate ( frontmatter . created ) ) errors . push ( "created must be a valid YYYY-MM-DD date" ) ;
192+ if ( frontmatter . expires !== undefined && frontmatter . expires !== "null" && ! isValidDate ( frontmatter . expires ) ) {
193+ errors . push ( "expires must be null or a valid YYYY-MM-DD date" ) ;
194+ }
195+ if ( ! Array . isArray ( frontmatter . tags ) || frontmatter . tags . length < 1 || frontmatter . tags . length > 10 ) {
196+ errors . push ( "tags must contain 1 to 10 items" ) ;
197+ } else {
198+ frontmatter . tags . forEach ( ( tag , index ) => {
199+ if ( typeof tag !== "string" || ! SLUG_PATTERN . test ( tag ) ) {
200+ errors . push ( `tags[${ index } ] must match ^[a-z0-9_][a-z0-9_-]*$` ) ;
201+ }
202+ } ) ;
203+ }
204+ if ( frontmatter . metadata !== undefined && ( typeof frontmatter . metadata !== "object" || Array . isArray ( frontmatter . metadata ) ) ) {
205+ errors . push ( "metadata must be an object" ) ;
206+ }
207+ if ( frontmatter . source !== undefined && typeof frontmatter . source !== "string" ) errors . push ( "source must be a string" ) ;
208+ if ( frontmatter . related !== undefined && ( ! Array . isArray ( frontmatter . related ) || frontmatter . related . some ( ( item ) => typeof item !== "string" ) ) ) {
209+ errors . push ( "related must be an array of strings" ) ;
210+ }
211+ return errors ;
212+ }
213+
139214// ─── Validators ─────────────────────────────────────────────────────────────────
140215
141216function validateDrift ( parsed ) {
@@ -161,7 +236,17 @@ function validateMissing(parsed, memoryRoot) {
161236 const section = parsed . sections [ type ] ;
162237 if ( ! section ) continue ;
163238 for ( const item of section . items ) {
164- const fullPath = path . join ( memoryRoot , item . path ) ;
239+ const fullPath = resolveMemoryPath ( memoryRoot , item . path ) ;
240+ if ( ! fullPath ) {
241+ issues . push ( {
242+ kind : "missing" ,
243+ type,
244+ line : item . line ,
245+ path : item . path ,
246+ detail : `MEMORY.md path ${ item . path } escapes the memory root` ,
247+ } ) ;
248+ continue ;
249+ }
165250 let stat = null ;
166251 try { stat = fs . statSync ( fullPath ) ; } catch ( _ ) { }
167252 const placeholder = path . basename ( item . path ) === ".gitkeep" ;
@@ -205,12 +290,15 @@ function validateSchema(memoryRoot) {
205290 continue ;
206291 }
207292 const missingKeys = REQUIRED_FRONTMATTER_KEYS . filter ( ( k ) => ! ( k in fm ) ) ;
208- if ( missingKeys . length > 0 ) {
293+ const valueErrors = missingKeys . length === 0 ? validateFrontmatterValues ( fm , type ) : [ ] ;
294+ if ( missingKeys . length > 0 || valueErrors . length > 0 ) {
209295 issues . push ( {
210296 kind : "schema" ,
211297 type,
212298 path : path . join ( type , file ) ,
213- detail : `topic file is missing frontmatter keys: ${ missingKeys . join ( ", " ) } ` ,
299+ detail : missingKeys . length > 0
300+ ? `topic file is missing frontmatter keys: ${ missingKeys . join ( ", " ) } `
301+ : `topic file frontmatter is invalid: ${ valueErrors . join ( "; " ) } ` ,
214302 } ) ;
215303 }
216304 }
@@ -354,16 +442,22 @@ function buildFixPlan(parsed, issues, memoryRoot) {
354442 // computation sees the *post-fix* item count (otherwise appending N
355443 // orphans to a section with declaredCount=K would still leave drift).
356444 const orphanAppendCount = Object . create ( null ) ;
445+ const duplicateRemovalCount = Object . create ( null ) ;
357446 for ( const issue of issues ) {
358- if ( issue . kind !== "orphan" ) continue ;
359- orphanAppendCount [ issue . type ] = ( orphanAppendCount [ issue . type ] || 0 ) + 1 ;
447+ if ( issue . kind === "orphan" ) {
448+ orphanAppendCount [ issue . type ] = ( orphanAppendCount [ issue . type ] || 0 ) + 1 ;
449+ } else if ( issue . kind === "duplicate" ) {
450+ duplicateRemovalCount [ issue . type ] = ( duplicateRemovalCount [ issue . type ] || 0 ) + 1 ;
451+ }
360452 }
361453
362454 // drift: rewrite section headers
363455 for ( const type of ALL_TYPES ) {
364456 const section = parsed . sections [ type ] ;
365457 if ( ! section ) continue ;
366- const projectedItems = section . items . length + ( orphanAppendCount [ type ] || 0 ) ;
458+ const projectedItems = section . items . length
459+ - ( duplicateRemovalCount [ type ] || 0 )
460+ + ( orphanAppendCount [ type ] || 0 ) ;
367461 if ( section . declaredCount !== projectedItems ) {
368462 const newHeader = `## ${ type } (${ projectedItems } /${ section . declaredCap } )` ;
369463 edits . push ( {
@@ -415,15 +509,13 @@ function buildFixPlan(parsed, issues, memoryRoot) {
415509 }
416510
417511 // duplicate: drop the duplicate line (keep first occurrence)
418- const duplicateLines = new Set ( ) ;
419512 const seen = new Set ( ) ;
420513 for ( const type of ALL_TYPES ) {
421514 const section = parsed . sections [ type ] ;
422515 if ( ! section ) continue ;
423516 seen . clear ( ) ;
424517 for ( const item of section . items ) {
425518 if ( seen . has ( item . path ) ) {
426- duplicateLines . add ( item . line ) ;
427519 edits . push ( {
428520 kind : "duplicate" ,
429521 type,
@@ -461,37 +553,29 @@ function applyFixPlan(parsed, plan, { confirm = false } = {}) {
461553 }
462554 if ( ! plan || ! plan . ok ) return { ok : false , applied : 0 } ;
463555 const lines = parsed . lines . slice ( ) ;
464- // Apply drift (replace header)
465- for ( const edit of plan . edits ) {
466- if ( edit . kind === "drift" ) {
467- lines [ edit . line - 1 ] = edit . after ;
468- }
556+ // Apply every original-line edit in descending order before any insertion,
557+ // so no operation can invalidate another edit's line number.
558+ const lineEdits = plan . edits
559+ . filter ( ( edit ) => edit . kind === "drift" || edit . kind === "duplicate" )
560+ . sort ( ( a , b ) => b . line - a . line ) ;
561+ for ( const edit of lineEdits ) {
562+ if ( edit . kind === "duplicate" ) lines . splice ( edit . line - 1 , 1 ) ;
563+ else lines [ edit . line - 1 ] = edit . after ;
469564 }
470- // Apply orphan (insert after the section header). Find the next "## "
471- // boundary for each edit before inserting .
565+ // Insert orphans after line-based edits. Locate the section by its stable
566+ // heading instead of stale source line numbers .
472567 const orphanInsertions = plan . edits
473568 . filter ( ( e ) => e . kind === "orphan" )
474- // Process in reverse line order so earlier insertions do not shift
475- // later line numbers.
476- . sort ( ( a , b ) => b . line - a . line ) ;
569+ . sort ( ( a , b ) => ALL_TYPES . indexOf ( b . type ) - ALL_TYPES . indexOf ( a . type ) ) ;
477570 for ( const edit of orphanInsertions ) {
478- const section = parsed . sections [ edit . type ] ;
479- let insertAt = edit . line ; // after header line
571+ const headerIndex = lines . findIndex ( ( line ) => new RegExp ( `^##\\s+${ edit . type } \\s*\\(` ) . test ( line ) ) ;
572+ if ( headerIndex === - 1 ) continue ;
573+ let insertAt = headerIndex + 1 ;
480574 while ( insertAt < lines . length && ! lines [ insertAt ] . match ( / ^ # # \s / ) ) {
481575 insertAt += 1 ;
482576 }
483577 lines . splice ( insertAt , 0 , edit . after ) ;
484578 }
485- // Apply duplicate (remove line). Process in reverse order so deletions
486- // do not shift earlier line numbers.
487- const duplicateDeletions = plan . edits
488- . filter ( ( e ) => e . kind === "duplicate" )
489- . sort ( ( a , b ) => b . line - a . line ) ;
490- for ( const edit of duplicateDeletions ) {
491- if ( edit . line >= 1 && edit . line <= lines . length ) {
492- lines . splice ( edit . line - 1 , 1 ) ;
493- }
494- }
495579 const newText = lines . join ( "\n" ) + ( parsed . text . endsWith ( "\n" ) ? "" : "\n" ) ;
496580 fs . writeFileSync ( parsed . indexPath , newText ) ;
497581 return { ok : true , applied : plan . edits . length , newText } ;
@@ -504,8 +588,10 @@ module.exports = {
504588 ALL_TYPES ,
505589 REQUIRED_FRONTMATTER_KEYS ,
506590 defaultMemoryRoot,
591+ resolveMemoryPath,
507592 parseMemoryIndex,
508593 parseFrontmatter,
594+ validateFrontmatterValues,
509595 validateMemory,
510596 buildFixPlan,
511597 applyFixPlan,
0 commit comments