@try-works/dsh-recursive-mode 0.5.0 → 0.6.1
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/README.md +21 -8
- package/lib/enforcement.d.ts +58 -0
- package/lib/index.js +597 -107
- package/lib/memory.d.ts +35 -2
- package/lib/phase-rules.d.ts +102 -0
- package/lib/policy-globs.d.ts +5 -0
- package/lib/training.d.ts +240 -2
- package/package.json +1 -1
- package/references/artifact-template.md +26 -61
- package/references/bodies/claude.md +1 -1
- package/references/bodies/copilot.md +1 -1
- package/references/bodies/cursorrules.md +4 -2
- package/references/bodies/memory-router.md +1 -1
- package/references/bodies/recursive-agents-router.md +4 -3
- package/references/bootstrap/RECURSIVE.md +21 -30
- package/src/bootstrap.ts +30 -14
- package/src/enforcement.ts +89 -5
- package/src/index.ts +795 -723
- package/src/memory.ts +50 -9
- package/src/phase-rules.ts +162 -1
- package/src/policy-globs.ts +28 -4
- package/src/runtime.ts +1962 -1945
- package/src/training.ts +634 -6
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 =
|
|
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 + ' (' +
|
|
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
|
-
|
|
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
|
+
}
|