@try-works/dsh-recursive-mode 0.5.0 → 0.6.0

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.
package/src/training.ts CHANGED
@@ -22,12 +22,31 @@
22
22
  * ⚠ SUPERSEDE, NEVER DELETE. An update appends a revision and a removal appends a tombstone, so the
23
23
  * history of a learning stays readable; and a PINNED entry is untouchable by every automatic path.
24
24
  */
25
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
26
26
  import { spawnSync } from 'node:child_process'
27
- import { join } from 'node:path'
27
+ import { basename, dirname, join } from 'node:path'
28
+ // T40: the phase-8 rule lives in the RULES module and the plane's contract lives in the LINTER, so
29
+ // neither is restated here. A writer that validated against its own copy of the linter's field list
30
+ // would be free to disagree with the linter about what a valid memory doc is.
31
+ import {
32
+ MEMORY_ALWAYS_AVAILABLE,
33
+ MEMORY_DOC_LOCATIONS,
34
+ MEMORY_PLANE_PREFIX,
35
+ MEMORY_PROVENANCE_FIELD,
36
+ PHASE8_MEMORY_ARTIFACT,
37
+ PHASE8_MEMORY_SECTION,
38
+ type MemoryDocKind,
39
+ } from './phase-rules.ts'
40
+ import {
41
+ MEMORY_ALLOWED_STATUSES,
42
+ MEMORY_ALLOWED_TYPES,
43
+ MEMORY_REQUIRED_FIELDS,
44
+ getMdFieldValue,
45
+ hasHeaderField,
46
+ } from './ts-lint.ts'
28
47
 
29
48
  /** The artifact whose lock marks a run as complete enough to learn from. */
30
- export const PHASE8_ARTIFACT = '08-memory-impact.md'
49
+ export const PHASE8_ARTIFACT = PHASE8_MEMORY_ARTIFACT
31
50
 
32
51
  /** The parent's exit codes, kept as names so a caller cannot mistake one failure for the other. */
33
52
  export const TRAINING_EXIT = {
@@ -308,13 +327,30 @@ export function runPhase8Trigger(
308
327
  *
309
328
  * ⚠ ONE ITEM PER RUN IS NAMED, so a reader can trace a learning back to the run that produced it —
310
329
  * and the group is never presented as more evidence than it is.
330
+ *
331
+ * ⚠ T40 — AND IT NOW CARRIES THE PLANE'S METADATA HEADER, which it did not before. `memory/domains/
332
+ * <subsystem>.md` is a doc the memory-plane lint validates like any other, and this renderer wrote a
333
+ * bare `# Learnings:` heading — so the plugin's own cross-run extraction produced a doc its own
334
+ * `lint_memory_plane` FAILS for nine missing fields. The extraction was right and its output shape was
335
+ * wrong, which is exactly the kind of defect a write surface exists to prevent.
311
336
  */
312
- export function renderGroupShard(group: TrainingGroup): string {
337
+ export function renderGroupShard(group: TrainingGroup, options: { lastValidated?: string } = {}): string {
338
+ const runs = [...new Set(group.items.map((item) => item.runId))]
313
339
  const lines = [
340
+ ...renderMemoryMetadata({
341
+ type: 'domain',
342
+ status: 'CURRENT',
343
+ scope: 'Learnings extracted for subsystem ' + group.subsystem + ' (' + group.mode + ') from ' + runs.length + ' run(s).',
344
+ sourceRuns: runs,
345
+ validatedAtCommit: 'extracted-at-run-close',
346
+ lastValidated: options.lastValidated ?? isoSeconds(),
347
+ tags: [group.subsystem, group.mode],
348
+ }).trimEnd().split('\n'),
349
+ '',
314
350
  '# Learnings: ' + group.subsystem,
315
351
  '',
316
352
  '- Mode: ' + group.mode,
317
- '- Runs: ' + group.runs + ' (' + [...new Set(group.items.map((item) => item.runId))].join(', ') + ')',
353
+ '- Runs: ' + group.runs + ' (' + runs.join(', ') + ')',
318
354
  '',
319
355
  ]
320
356
  for (const item of group.items) {
@@ -501,8 +537,20 @@ export function taskTypeShardPath(mode: TrainingGroup['mode']): string {
501
537
  return 'memory/training/' + mode + '.md'
502
538
  }
503
539
 
504
- export function renderTaskTypeShard(groups: readonly TrainingGroup[]): string {
540
+ /** T40: same metadata-header reason as {@link renderGroupShard} — see the note there. */
541
+ export function renderTaskTypeShard(groups: readonly TrainingGroup[], options: { lastValidated?: string } = {}): string {
542
+ const runs = [...new Set(groups.flatMap((group) => group.items.map((item) => item.runId)))]
505
543
  const lines = [
544
+ ...renderMemoryMetadata({
545
+ type: 'pattern',
546
+ status: 'CURRENT',
547
+ scope: 'Training shards extracted under mode ' + groups[0].mode + ', one section per subsystem group.',
548
+ sourceRuns: runs,
549
+ validatedAtCommit: 'extracted-at-run-close',
550
+ lastValidated: options.lastValidated ?? isoSeconds(),
551
+ tags: ['training', groups[0].mode],
552
+ }).trimEnd().split('\n'),
553
+ '',
506
554
  '# Training shards: ' + groups[0].mode,
507
555
  '',
508
556
  'Groups extracted under this mode, one section each. Learning happens through files, not model mutation.',
@@ -563,3 +611,583 @@ export function extractAndGroup(
563
611
  const items = parseExtractorItems(outcome.payload)
564
612
  return { outcome, items, groups: groupLearnings(items, options.isWinner ?? (() => true)) }
565
613
  }
614
+
615
+ /* -------------------------------------------------------------------------- */
616
+ /* T40 — THE PHASE-8 WRITE SURFACE, AND THE GATE THAT PROVES IT HAPPENED */
617
+ /* -------------------------------------------------------------------------- */
618
+
619
+ /**
620
+ * T40 — THE PLUGIN'S OWN WAY TO WRITE `.recursive/memory/`.
621
+ *
622
+ * ⚠ WHY A WRITE SURFACE AND NOT ANOTHER PARAGRAPH OF INSTRUCTIONS. The memory plane's shape is not a
623
+ * convention an author can guess: a durable doc must carry nine metadata fields, its `Type` must be
624
+ * one of five, and the plane's own lint FAILs the whole run when one is missing. Measured against the
625
+ * live workspace, the plane held NOTHING but the bootstrap placeholders — so the practical choice was
626
+ * between an agent hand-rolling a doc that fails the plane lint and no doc at all. This renders the
627
+ * canonical shape, stamps the provenance the phase-8 gate reads back, replaces the shard's line in
628
+ * the registry, and refuses to write a doc its own linter would reject.
629
+ *
630
+ * ⚠ WHAT IS DELIBERATE, DECIDED HERE RATHER THAN LEFT TO A CALLER:
631
+ *
632
+ * - ATOMIC. `writeFileSync` to a sibling temp name, then a rename over the target. A memory doc is
633
+ * read by the loader and linted by the plane, and a torn write is worse than a missing one: a
634
+ * half-written doc is a FAILED lint that names a file nobody wrote, and there is no way to tell it
635
+ * from a real one afterwards.
636
+ * - PROVENANCE-CARRYING. `Source-Runs` names the run, and it is not decoration: it is the entire
637
+ * discriminator between "the run wrote its memory" and "the run cited a shard that already
638
+ * existed", which is the distinction the phase-8 gate turns on. It is also why the run id is
639
+ * stamped from the CALL rather than trusted per-doc.
640
+ * - DEDUPLICATED, IDEMPOTENTLY. Writing the same doc again from the same run is a no-op that SAYS
641
+ * so (`UNCHANGED`), because phase 8 closeout runs more than once — the training trigger's own
642
+ * reason for existing is the re-run — and a re-run must not duplicate a lesson or rewrite a doc
643
+ * byte-differently for no reason.
644
+ * - REFUSED WHEN THE ENTRY ALREADY EXISTS, unless the caller supersedes explicitly. Another run's
645
+ * shard is that run's evidence; silently overwriting it destroys provenance for a shard the plane
646
+ * could not restore. `supersede: true` is the deliberate path, and it ARCHIVES the previous
647
+ * revision under `memory/archive/` first — supersede, never delete, the same discipline the
648
+ * counters and the registry already follow.
649
+ * - AND NOTHING IS INVENTED. An empty scope or body, an unknown kind, a slug that is not a name, or
650
+ * a doc the linter would reject all come back as typed results with `written: false` — "zero
651
+ * writes" is a property a test asserts by listing the tree, not a promise in a comment.
652
+ *
653
+ * ⚠ THIS WRITES THIS PLUGIN'S MEMORY AND NOTHING ELSE. It never calls, wraps or delegates to another
654
+ * plugin's memory tools: the plane it touches is the `.recursive/memory/` tree this plugin scaffolds
655
+ * and lints, reached through this repo's own paths.
656
+ */
657
+ export interface MemoryDocSpec {
658
+ kind: MemoryDocKind
659
+ /** The run that wrote it. Stamped into `Source-Runs`, which is what the phase-8 gate reads back. */
660
+ runId: string
661
+ /** Filename stem (`03-ambientcss-redesign`). Sanitised: a slug is a NAME, never a path. */
662
+ slug: string
663
+ /** Defaults to the slug. */
664
+ title?: string
665
+ /** What the doc is about, in one sentence — the field a later run ranks on. */
666
+ scope: string
667
+ /** The lesson itself. Empty is REFUSED rather than padded: an invented lesson is not memory. */
668
+ body: string
669
+ status?: string
670
+ ownsPaths?: readonly string[]
671
+ watchPaths?: readonly string[]
672
+ tags?: readonly string[]
673
+ /** The commit the doc was validated against. Defaults to a token NAMING the run (see below). */
674
+ validatedAtCommit?: string
675
+ /** ISO timestamp. Defaults to the moment of the write, which is measured rather than invented. */
676
+ lastValidated?: string
677
+ parent?: string
678
+ /**
679
+ * Runs that contributed BEFORE this one, kept in `Source-Runs` when this write replaces a doc.
680
+ * Set by the writer on a supersede/update; a caller may set it, and the union is what keeps a
681
+ * replaced revision's history readable.
682
+ */
683
+ priorRuns?: readonly string[]
684
+ }
685
+
686
+ export interface MemoryWriteOptions {
687
+ /**
688
+ * Replace a shard ANOTHER RUN owns, by archiving it under `memory/archive/` first. Default false:
689
+ * the write is REFUSED and the refusal names both remedies.
690
+ */
691
+ supersede?: boolean
692
+ /** The clock, injected so the caller (and a test) decides what "now" is. */
693
+ now?: () => Date
694
+ }
695
+
696
+ export type MemoryWriteCode = 'WRITTEN' | 'UPDATED' | 'UNCHANGED' | 'REFUSED' | 'INVALID'
697
+
698
+ export interface MemoryWriteResult {
699
+ code: MemoryWriteCode
700
+ /** Repo-relative path (`memory/episodes/<slug>.md`); EMPTY only when no path could be resolved. */
701
+ path: string
702
+ /** True only for `WRITTEN` and `UPDATED`. */
703
+ written: boolean
704
+ /** Where a superseded revision was archived, when that happened. */
705
+ archived: string | null
706
+ /** Always says what happened — a silent refusal is a lost work item. */
707
+ reason: string
708
+ }
709
+
710
+ /** `2026-10-10T08:39:59Z` — the lock fields' own timestamp shape, and the docs' `Last-Validated` one. */
711
+ export function isoSeconds(now: Date = new Date()): string {
712
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z')
713
+ }
714
+
715
+ /** A slug is a FILE NAME: no separator, no traversal, no extension, no leading dot. */
716
+ export function sanitizeMemorySlug(slug: string): string {
717
+ return slug
718
+ .trim()
719
+ .replace(/\.md$/i, '')
720
+ .replace(/[\\/]+/g, '-')
721
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
722
+ .replace(/^[.\-]+/, '')
723
+ .replace(/[.\-]+$/, '')
724
+ }
725
+
726
+ /** The repo-relative path a doc gets, or null when the kind or the slug cannot name one. */
727
+ export function memoryDocRelativePath(kind: MemoryDocKind, slug: string): string | null {
728
+ const location = MEMORY_DOC_LOCATIONS[kind]
729
+ if (location === undefined) return null
730
+ const clean = sanitizeMemorySlug(slug)
731
+ if (clean === '') return null
732
+ return location.dir + '/' + clean + '.md'
733
+ }
734
+
735
+ function bullets(values: readonly string[] | undefined): string[] {
736
+ return (values ?? []).map((value) => '- `' + value.replace(/`/g, "'") + '`')
737
+ }
738
+
739
+ /**
740
+ * The metadata header every durable doc carries: the nine fields `lint_memory_doc` requires, in the
741
+ * order the SHIPPED docs use them (`Owns-Paths:` / `Watch-Paths:` / `Tags:` stand bare when empty,
742
+ * which is what the workspace's own promoted docs do and what `has_header_field` accepts).
743
+ *
744
+ * ⚠ A BACKTICK INSIDE A FIELD VALUE IS REPLACED, NOT ESCAPED, because these values are read back by
745
+ * a line-based field reader: a stray backtick would end the value early and leave the rest of the
746
+ * sentence in the doc as if it were a field.
747
+ */
748
+ export function renderMemoryMetadata(input: {
749
+ type: string
750
+ status: string
751
+ scope: string
752
+ sourceRuns: readonly string[]
753
+ validatedAtCommit: string
754
+ lastValidated: string
755
+ ownsPaths?: readonly string[]
756
+ watchPaths?: readonly string[]
757
+ tags?: readonly string[]
758
+ parent?: string
759
+ }): string {
760
+ const lines = [
761
+ 'Type: `' + input.type + '`',
762
+ 'Status: `' + input.status + '`',
763
+ 'Scope: `' + input.scope.replace(/`/g, "'") + '`',
764
+ 'Owns-Paths:',
765
+ ...bullets(input.ownsPaths),
766
+ 'Watch-Paths:',
767
+ ...bullets(input.watchPaths),
768
+ MEMORY_PROVENANCE_FIELD + ':',
769
+ ...bullets(input.sourceRuns),
770
+ 'Validated-At-Commit: `' + input.validatedAtCommit.replace(/`/g, "'") + '`',
771
+ 'Last-Validated: `' + input.lastValidated.replace(/`/g, "'") + '`',
772
+ 'Tags:',
773
+ ...bullets(input.tags),
774
+ ]
775
+ if (input.parent !== undefined && input.parent.trim() !== '') lines.push('Parent: `' + input.parent.replace(/`/g, "'") + '`')
776
+ return lines.join('\n') + '\n'
777
+ }
778
+
779
+ /**
780
+ * Render the whole doc: the canonical header, then the title, then the lesson.
781
+ *
782
+ * ⚠ `Validated-At-Commit` DEFAULTS TO A TOKEN THAT NAMES THE RUN, NOT A SHA. A run writing its own
783
+ * lesson has no commit to be validated against yet, and a placeholder SHA would be a fabricated fact
784
+ * in the one field a reader uses to decide whether the lesson still holds. The shipped generic docs
785
+ * use the same convention (`generic-repository-guidance`).
786
+ */
787
+ export function renderMemoryDoc(spec: MemoryDocSpec, options: { lastValidated?: string } = {}): string {
788
+ const location = MEMORY_DOC_LOCATIONS[spec.kind]
789
+ const sourceRuns = [...new Set([spec.runId, ...(spec.priorRuns ?? [])])].filter((run) => run.trim() !== '')
790
+ const header = renderMemoryMetadata({
791
+ type: location.type,
792
+ status: spec.status ?? 'CURRENT',
793
+ scope: spec.scope,
794
+ sourceRuns,
795
+ validatedAtCommit: spec.validatedAtCommit ?? 'written-by-run-' + spec.runId,
796
+ lastValidated: spec.lastValidated ?? options.lastValidated ?? isoSeconds(),
797
+ ownsPaths: spec.ownsPaths,
798
+ watchPaths: spec.watchPaths,
799
+ tags: spec.tags,
800
+ parent: spec.parent,
801
+ })
802
+ return header + '\n# ' + (spec.title ?? spec.slug) + '\n\n' + spec.body.trim() + '\n'
803
+ }
804
+
805
+ /**
806
+ * The problems that would make the memory plane FAIL this doc — checked with the LINTER'S own field
807
+ * list, allowed Types and allowed Statuses, so this cannot accept a doc the plane rejects.
808
+ */
809
+ export function memoryDocProblems(content: string): string[] {
810
+ const problems: string[] = []
811
+ const missing = MEMORY_REQUIRED_FIELDS.filter((field) => !hasHeaderField(content, field))
812
+ if (missing.length > 0) problems.push('missing required memory metadata field(s): ' + missing.join(', '))
813
+ const type = (getMdFieldValue(content, 'Type') ?? '').toLowerCase()
814
+ if (!MEMORY_ALLOWED_TYPES.has(type)) {
815
+ problems.push("Type '" + type + "' is not one the memory plane accepts (expected one of: " + [...MEMORY_ALLOWED_TYPES].sort().join(', ') + ')')
816
+ }
817
+ const status = (getMdFieldValue(content, 'Status') ?? '').toUpperCase()
818
+ if (!MEMORY_ALLOWED_STATUSES.has(status)) {
819
+ problems.push("Status '" + status + "' is not one the memory plane accepts (expected one of: " + [...MEMORY_ALLOWED_STATUSES].sort().join(', ') + ')')
820
+ }
821
+ return problems
822
+ }
823
+
824
+ function unquoteMemoryValue(value: string): string {
825
+ const trimmed = value.trim()
826
+ for (const quote of ['`', '"', "'"]) {
827
+ if (trimmed.length >= 2 && trimmed.startsWith(quote) && trimmed.endsWith(quote)) {
828
+ return trimmed.slice(1, -1).trim()
829
+ }
830
+ }
831
+ return trimmed
832
+ }
833
+
834
+ /**
835
+ * The values of a list field (`Source-Runs:`), inline or as bullets.
836
+ *
837
+ * ⚠ THE BLOCK ENDS AT THE FIRST BLANK LINE, HEADING OR NEW FIELD, and that strictness is the point:
838
+ * the alternative is a parser that reads an unrelated bullet list further down the document as
839
+ * provenance — and provenance is the one thing here that must never be guessed.
840
+ */
841
+ export function parseMemoryListField(content: string, fieldName: string): string[] {
842
+ const fieldRe = new RegExp('^[ \\t]*(?:[-*][ \\t]+)?' + fieldName + ':[ \\t]*(.*)$')
843
+ const values: string[] = []
844
+ let inField = false
845
+ for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
846
+ const field = fieldRe.exec(line)
847
+ if (field !== null) {
848
+ inField = true
849
+ const inline = unquoteMemoryValue(field[1])
850
+ if (inline !== '') values.push(inline)
851
+ continue
852
+ }
853
+ if (!inField) continue
854
+ const item = /^[ \t]*[-*][ \t]+(.+)$/.exec(line)
855
+ if (item !== null) {
856
+ const value = unquoteMemoryValue(item[1])
857
+ if (value !== '') values.push(value)
858
+ continue
859
+ }
860
+ // A blank line, a heading or the next field ends the block — see the note above: reading further
861
+ // would let an unrelated list elsewhere in the doc become provenance.
862
+ break
863
+ }
864
+ return values
865
+ }
866
+
867
+ /** The runs a doc's `Source-Runs` names. EXACT matches: `run-1` is not `run-10`. */
868
+ export function memoryDocProvenance(content: string): string[] {
869
+ return parseMemoryListField(content, MEMORY_PROVENANCE_FIELD)
870
+ }
871
+
872
+ function readTextOrNull(path: string): string | null {
873
+ try {
874
+ return readFileSync(path, 'utf8')
875
+ } catch {
876
+ return null
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Write bytes so a reader sees either the old file or the new one, never a mixture: a sibling temp
882
+ * name, then a rename over the target.
883
+ */
884
+ function writeTextAtomic(path: string, content: string): void {
885
+ mkdirSync(dirname(path), { recursive: true })
886
+ const temp = path + '.tmp-' + process.pid.toString(36) + '-' + Date.now().toString(36)
887
+ try {
888
+ writeFileSync(temp, content, 'utf8')
889
+ renameSync(temp, path)
890
+ } catch (err) {
891
+ try {
892
+ rmSync(temp, { force: true })
893
+ } catch {
894
+ // A leftover temp file is a wart; the write failure is the news, and swallowing it here would be
895
+ // the only way to lose it.
896
+ }
897
+ throw err
898
+ }
899
+ }
900
+
901
+ /** The task-type token a shard's registry line carries, from the directory it lives in. */
902
+ export function taskTypeForMemoryPath(path: string): string {
903
+ // The plane is spelled BOTH ways in this codebase — `.recursive/memory/…` (the scaffolded plane, the
904
+ // linter, the phase-8 artifact) and the trigger's older `memory/…` (its call site joins that onto the
905
+ // workspace root) — so the token is derived from the directory NAME and is insensitive to the base.
906
+ const normalized = path.replace(/\\/g, '/').replace(/^\.recursive\//, '')
907
+ for (const [kind, location] of Object.entries(MEMORY_DOC_LOCATIONS)) {
908
+ if (normalized.startsWith(location.dir.replace(/^\.recursive\//, '') + '/')) return kind
909
+ }
910
+ const training = /^memory\/training\/(.+)\.md$/.exec(normalized)
911
+ return training === null ? 'memory' : training[1]
912
+ }
913
+
914
+ /**
915
+ * Write ONE durable doc, atomically, with provenance — and refuse rather than guess.
916
+ *
917
+ * The four decided behaviours (see {@link MemoryDocSpec}'s block comment): atomic, provenance-carrying,
918
+ * idempotent for the same run, and refused when another run owns the path unless `supersede` is
919
+ * explicit — in which case the previous revision is ARCHIVED first, so nothing is ever deleted.
920
+ */
921
+ export function writeMemoryDoc(root: string, spec: MemoryDocSpec, options: MemoryWriteOptions = {}): MemoryWriteResult {
922
+ const path = memoryDocRelativePath(spec.kind, spec.slug)
923
+ if (path === null) {
924
+ return {
925
+ code: 'INVALID',
926
+ path: '',
927
+ written: false,
928
+ archived: null,
929
+ reason: "kind '" + String(spec.kind) + "' has no location in the memory plane, or the slug is empty, so NOTHING was written",
930
+ }
931
+ }
932
+ if (spec.scope.trim() === '' || spec.body.trim() === '') {
933
+ return {
934
+ code: 'INVALID',
935
+ path,
936
+ written: false,
937
+ archived: null,
938
+ reason: 'Scope and body are both required and neither may be empty: a doc whose lesson nobody wrote is not memory, so NOTHING was written',
939
+ }
940
+ }
941
+ const content = renderMemoryDoc(spec, options.now === undefined ? {} : { lastValidated: isoSeconds(options.now()) })
942
+ const problems = memoryDocProblems(content)
943
+ if (problems.length > 0) {
944
+ return {
945
+ code: 'INVALID',
946
+ path,
947
+ written: false,
948
+ archived: null,
949
+ reason: 'the rendered doc would FAIL the memory-plane lint (' + problems.join('; ') + '), so NOTHING was written',
950
+ }
951
+ }
952
+
953
+ const absolute = join(root, path)
954
+ const existing = readTextOrNull(absolute)
955
+ if (existing === null) {
956
+ writeTextAtomic(absolute, content)
957
+ return { code: 'WRITTEN', path, written: true, archived: null, reason: 'wrote ' + path + ' with ' + MEMORY_PROVENANCE_FIELD + ' naming ' + spec.runId }
958
+ }
959
+
960
+ const owners = memoryDocProvenance(existing)
961
+ if (owners.includes(spec.runId)) {
962
+ if (existing === content) {
963
+ return {
964
+ code: 'UNCHANGED',
965
+ path,
966
+ written: false,
967
+ archived: null,
968
+ reason: path + ' is already written by ' + spec.runId + ' byte-for-byte, so this write was a no-op (phase 8 closeout re-runs, and a re-run must not duplicate a lesson)',
969
+ }
970
+ }
971
+ // ⚠ THIS RUN MAY CORRECT ITS OWN DOC. Refusing here would make a typo unfixable by the only party
972
+ // entitled to fix it, so the doc is replaced — and the Source-Runs of every earlier contributor
973
+ // are carried forward rather than dropped with the old revision.
974
+ const updatedContent = renderMemoryDoc({ ...spec, priorRuns: [...owners, ...(spec.priorRuns ?? [])] }, options.now === undefined ? {} : { lastValidated: isoSeconds(options.now()) })
975
+ writeTextAtomic(absolute, updatedContent)
976
+ return { code: 'UPDATED', path, written: true, archived: null, reason: 'replaced ' + path + ', which this run already owned, keeping every earlier ' + MEMORY_PROVENANCE_FIELD + ' entry' }
977
+ }
978
+
979
+ if (options.supersede !== true) {
980
+ return {
981
+ code: 'REFUSED',
982
+ path,
983
+ written: false,
984
+ archived: null,
985
+ reason: path + ' already exists and its ' + MEMORY_PROVENANCE_FIELD + ' names ' + owners.join(', ') + ' rather than ' + spec.runId
986
+ + ', so it was NOT overwritten: pass supersede to replace it (the previous revision is ARCHIVED, never deleted), or file this lesson under a distinct slug',
987
+ }
988
+ }
989
+
990
+ // Supersede: ARCHIVE FIRST. A crash between the two writes leaves the previous revision readable in
991
+ // the archive and the original still in place — recoverable, and never a deleted learning.
992
+ const archived = 'memory/archive/' + basename(path).replace(/\.md$/, '') + '.' + sanitizeMemorySlug(owners[0] ?? 'unknown') + '.md'
993
+ writeTextAtomic(join(root, archived), existing)
994
+ writeTextAtomic(absolute, renderMemoryDoc({ ...spec, priorRuns: [...owners, ...(spec.priorRuns ?? [])] }, options.now === undefined ? {} : { lastValidated: isoSeconds(options.now()) }))
995
+ return { code: 'WRITTEN', path, written: true, archived, reason: 'superseded ' + path + ' (previous revision archived at ' + archived + ') with ' + MEMORY_PROVENANCE_FIELD + ' naming ' + spec.runId }
996
+ }
997
+
998
+ export interface RunMemoryWriteResult {
999
+ /** Repo-relative paths actually written (`WRITTEN` or `UPDATED`), in order. */
1000
+ writes: string[]
1001
+ results: MemoryWriteResult[]
1002
+ /** The registry write (`memory/MEMORY.md`), or null when nothing was written. */
1003
+ registry: MemoryWriteResult | null
1004
+ /** What happened, INCLUDING the failure sentence — a caller must not have to infer it. */
1005
+ reason: string
1006
+ }
1007
+
1008
+ /**
1009
+ * T40 — THE PHASE-8 CALL: write this run's durable docs and register them.
1010
+ *
1011
+ * ⚠ THE RUN ID COMES FROM THE ARGUMENT, NOT FROM EACH SPEC. A spec that named a different run would
1012
+ * write provenance the phase-8 gate then refuses — a doc claiming a write this run did not make —
1013
+ * and the caller would be left holding a file that blocks its own lock. One run per call removes
1014
+ * that possibility instead of documenting it.
1015
+ *
1016
+ * ⚠ THE REGISTRY IS REFRESHED ONLY WHEN SOMETHING WAS WRITTEN, because the failure paths must write
1017
+ * NOTHING (the module's standing contract) and because a registry refreshed over an unchanged plane
1018
+ * is a claim that something changed. `updateMemoryRegistry` is reused rather than reimplemented: a
1019
+ * shard's line is REPLACED, never duplicated, and a shard is never removed.
1020
+ *
1021
+ * ⚠ AND IT IS THE PLANE'S REGISTRY, `.recursive/memory/MEMORY.md` — the file `MEMORY_INDEX_FILE`
1022
+ * names and the same one `bootstrap.ts` marker-upserts. The trigger's older `memory/MEMORY.md` is read
1023
+ * as a FALLBACK so a registry the previous call site wrote is not silently discarded, and it is never
1024
+ * written to: two registries in one workspace would be two answers to "what does this plane hold".
1025
+ */
1026
+ export function writeRunMemory(
1027
+ root: string,
1028
+ runId: string,
1029
+ specs: readonly MemoryDocSpec[],
1030
+ options: MemoryWriteOptions = {},
1031
+ ): RunMemoryWriteResult {
1032
+ if (specs.length === 0) {
1033
+ return {
1034
+ writes: [],
1035
+ results: [],
1036
+ registry: null,
1037
+ reason: 'no memory docs were supplied, so NOTHING was written — phase 8 requires at least one, and ' + MEMORY_ALWAYS_AVAILABLE + ' is always available',
1038
+ }
1039
+ }
1040
+ const results = specs.map((spec) => writeMemoryDoc(root, { ...spec, runId }, options))
1041
+ const writes = results.filter((result) => result.written).map((result) => result.path)
1042
+ if (writes.length === 0) {
1043
+ return {
1044
+ writes,
1045
+ results,
1046
+ registry: null,
1047
+ reason: 'NOTHING was written: ' + results.map((result) => result.code + ' ' + (result.path === '' ? '(no path)' : result.path) + ' — ' + result.reason).join('; '),
1048
+ }
1049
+ }
1050
+ const registryPath = '.recursive/memory/MEMORY.md'
1051
+ const existing = readTextOrNull(join(root, registryPath)) ?? readTextOrNull(join(root, 'memory/MEMORY.md')) ?? ''
1052
+ const updated = updateMemoryRegistry(existing, writes.map((path) => ({ path, taskType: taskTypeForMemoryPath(path) })))
1053
+ const changed = updated !== existing
1054
+ if (changed) writeTextAtomic(join(root, registryPath), updated)
1055
+ return {
1056
+ writes,
1057
+ results,
1058
+ registry: {
1059
+ code: changed ? 'WRITTEN' : 'UNCHANGED',
1060
+ path: registryPath,
1061
+ written: changed,
1062
+ archived: null,
1063
+ reason: changed
1064
+ ? 'registered ' + writes.length + ' shard(s) in ' + registryPath + ' (a shard\'s line is REPLACED, never duplicated, and never removed)'
1065
+ : registryPath + ' already registered every shard this call wrote',
1066
+ },
1067
+ reason: 'wrote ' + writes.length + ' memory doc(s): ' + writes.join(', '),
1068
+ }
1069
+ }
1070
+
1071
+ /** Every `.recursive/memory/**` path a text declares, in the absolute or repo-relative spelling. */
1072
+ export function phase8MemoryRefs(text: string): string[] {
1073
+ const found = new Set<string>()
1074
+ const absolute = /(?:^|[\s(`'"<[])\/?\.recursive\/memory\/[A-Za-z0-9._@\-/]*\.md/g
1075
+ const relative = /(?:^|[\s(`'"<[])(memory\/(?:domains|patterns|incidents|episodes|training|skills|archive)\/[A-Za-z0-9._@\-/]*\.md)/g
1076
+ let match: RegExpExecArray | null
1077
+ while ((match = absolute.exec(text)) !== null) {
1078
+ found.add(match[0].replace(/^[\s(`'"<[]/, '').replace(/^\/+/, ''))
1079
+ }
1080
+ while ((match = relative.exec(text)) !== null) {
1081
+ found.add(MEMORY_PLANE_PREFIX + match[1].slice('memory/'.length))
1082
+ }
1083
+ return [...found].sort()
1084
+ }
1085
+
1086
+ export interface Phase8MemoryEvidence {
1087
+ ok: boolean
1088
+ /** Paths under the plane the artifact declares, repo-relative to the repo root (sorted, deduped). */
1089
+ declared: string[]
1090
+ /** Declared paths that exist on disk. */
1091
+ existing: string[]
1092
+ /** Declared paths whose own text carries THIS run's provenance: the writes that COUNT. */
1093
+ written: string[]
1094
+ reason: string
1095
+ }
1096
+
1097
+ /**
1098
+ * T40 — THE CHECKABLE FACT BEHIND "the run wrote its durable memory".
1099
+ *
1100
+ * Three conditions, and each one exists because of a way the claim could be made without the work
1101
+ * being done: the artifact DECLARES a path under the plane (not prose about memory in general); the
1102
+ * path EXISTS (a declaration is not a write); and the doc on disk carries `Source-Runs` naming THIS
1103
+ * run (a shard the run merely read, or one an earlier run wrote, is not this run's memory).
1104
+ *
1105
+ * ⚠ WHAT IT CANNOT TELL, stated rather than hidden: WHEN the doc was written. A run that wrote a
1106
+ * memory doc before phase 8 and cites it here passes — and the phase 6/7 baselines deny memory-plane
1107
+ * writes outright (`phaseBaselineRules`), so within this workflow the only phase that can produce
1108
+ * such a doc is 8. The check is about the FACT existing at lock time, not about the clock.
1109
+ */
1110
+ export function phase8MemoryEvidence(
1111
+ root: string,
1112
+ runId: string,
1113
+ artifactText: string,
1114
+ readText: (path: string) => string | null = readTextOrNull,
1115
+ ): Phase8MemoryEvidence {
1116
+ const declared = phase8MemoryRefs(artifactText)
1117
+ if (declared.length === 0) {
1118
+ return { ok: false, declared, existing: [], written: [], reason: 'the artifact declares no path under ' + MEMORY_PLANE_PREFIX + ' at all' }
1119
+ }
1120
+ const existing: string[] = []
1121
+ const written: string[] = []
1122
+ for (const path of declared) {
1123
+ const text = readText(join(root, path))
1124
+ if (text === null) continue
1125
+ existing.push(path)
1126
+ if (memoryDocProvenance(text).includes(runId)) written.push(path)
1127
+ }
1128
+ if (written.length > 0) {
1129
+ return {
1130
+ ok: true,
1131
+ declared,
1132
+ existing,
1133
+ written,
1134
+ reason: written.length + ' declared memory doc(s) carry ' + MEMORY_PROVENANCE_FIELD + ' naming ' + runId + ': ' + written.join(', '),
1135
+ }
1136
+ }
1137
+ if (existing.length === 0) {
1138
+ return {
1139
+ ok: false,
1140
+ declared,
1141
+ existing,
1142
+ written,
1143
+ reason: 'the artifact declares ' + declared.length + ' path(s) under ' + MEMORY_PLANE_PREFIX + ' (' + declared.join(', ') + '), but none of them exists, so no memory doc was written',
1144
+ }
1145
+ }
1146
+ return {
1147
+ ok: false,
1148
+ declared,
1149
+ existing,
1150
+ written,
1151
+ reason: 'the artifact declares ' + existing.length + ' existing memory doc(s) (' + existing.join(', ') + '), but none carries ' + MEMORY_PROVENANCE_FIELD + ' naming run ' + runId + ' — citing a shard this run did not write is not a write',
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * T40 — THE REFUSAL, as a sentence, or null when the run may lock.
1157
+ *
1158
+ * ⚠ THE MESSAGE NAMES THE REMEDY, not only the fault. A refusal that says "no memory doc" leaves the
1159
+ * agent to guess a format it cannot guess (nine fields, five allowed Types, a provenance list), which
1160
+ * is how the step became a ticked box in the first place.
1161
+ */
1162
+ export function phase8MemoryRefusal(
1163
+ root: string,
1164
+ runId: string,
1165
+ artifactText: string,
1166
+ readText: (path: string) => string | null = readTextOrNull,
1167
+ ): string | null {
1168
+ const evidence = phase8MemoryEvidence(root, runId, artifactText, readText)
1169
+ if (evidence.ok) return null
1170
+ return 'locking ' + PHASE8_ARTIFACT + ' requires this run to have WRITTEN a doc under ' + MEMORY_PLANE_PREFIX + ': ' + evidence.reason
1171
+ + '. Write one (memory/episodes/' + runId + '.md is always available), declare its path under `## ' + PHASE8_MEMORY_SECTION
1172
+ + '`, and give the doc `' + MEMORY_PROVENANCE_FIELD + ': ' + runId + '` — then retry the lock.'
1173
+ }
1174
+
1175
+ /**
1176
+ * T40 — THE LOCK-TIME ENTRY POINT: the refusal for the artifact being locked, or null.
1177
+ *
1178
+ * ⚠ THIS EXISTS SO THE GATE IS ONE LINE AT ITS CALL SITE. The decision (declared → exists → carries
1179
+ * this run's provenance) belongs in this module with the write surface that produces it; `lockArtifact`
1180
+ * should have to say only WHICH artifact it is locking, not how a memory doc is recognised. A caller
1181
+ * that has to reproduce the rule would be a second copy of it.
1182
+ *
1183
+ * ⚠ AND A MISSING ARTIFACT IS NOT THIS GATE'S REFUSAL. `lockArtifact` already refuses an absent
1184
+ * artifact before any gate can run, and returning a memory refusal for a file that does not exist
1185
+ * would replace "the artifact is missing" with a sentence about memory — a misleading diagnosis in
1186
+ * exchange for nothing.
1187
+ */
1188
+ export function phase8MemoryLockRefusal(root: string, runId: string, artifact: string): string | null {
1189
+ if (artifact !== PHASE8_ARTIFACT) return null
1190
+ const artifactText = readTextOrNull(join(root, '.recursive', 'run', runId, PHASE8_ARTIFACT))
1191
+ if (artifactText === null) return null
1192
+ return phase8MemoryRefusal(root, runId, artifactText)
1193
+ }