@try-works/dsh-recursive-mode 0.6.0 → 0.7.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/lib/index.js CHANGED
@@ -640,6 +640,277 @@ function jsonValueIndent(value, depth) {
640
640
  return "null";
641
641
  }
642
642
  //#endregion
643
+ //#region src/memory-feedback.ts
644
+ /**
645
+ * Memory feedback (FU-13 P3) — which entries were injected into which run, and how that run turned out.
646
+ *
647
+ * ⚠ WHAT THIS IS, HONESTLY LABELLED: a COUNTER, not learning. `TRAINING.md` puts model training (the
648
+ * reference's 58 KB GRPO script) out of scope for a workflow plugin. What is in scope is the loop that makes
649
+ * retrieval improve with evidence: record what was injected, settle it against the run's outcome, and let the
650
+ * selector prefer what has held up.
651
+ *
652
+ * ⚠ AND WHY THE COUNTERS LIVE IN A SIDECAR, NOT IN THE SHARDS. The memory plane's documents are
653
+ * human-authored: a person writes the lesson. Rewriting one to bump a number would be the silent-overwrite
654
+ * defect this repo already produced once (the closeout writing over phase documents), applied to the memory
655
+ * plane. So the counters live in `.recursive/memory/.feedback.json`, which is machine-owned and disposable —
656
+ * delete it and you lose ranking evidence, not knowledge.
657
+ *
658
+ * ⚠ NO TIMESTAMPS ANYWHERE: two runs with the same inputs must produce identical files, which is the same
659
+ * discipline the lock receipts, the closeout receipts and the selection output all follow.
660
+ */
661
+ /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
662
+ const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
663
+ /**
664
+ * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
665
+ *
666
+ * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
667
+ * not the plugin's to discard, and without the fallback the first settle after the move would start the
668
+ * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
669
+ * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
670
+ * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
671
+ * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
672
+ * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
673
+ * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
674
+ * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
675
+ * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
676
+ */
677
+ const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
678
+ /** Where a run records what it was shown. */
679
+ const INJECTIONS_FILE = "memory-injections.json";
680
+ /**
681
+ * THE SUBJECT A READ-RECEIPT CARRIES, AND WHY IT CANNOT COLLIDE WITH A SHARD.
682
+ *
683
+ * ⚠ THE GAP THIS CLOSES, MEASURED. This file used to record only the SHARDS a phase was shown, so a phase
684
+ * whose selection came back empty — the documented, legitimate state of a workspace with no memory plane —
685
+ * left NO trace at all. "A record exists" and "the read happened" were therefore the same sentence, and a
686
+ * gate that required the former would REFUSE FOREVER in a fresh workspace: there would be nothing to
687
+ * record, so nothing would ever be recorded. `selectMemory` says it plainly (`memory.ts`): *"the memory
688
+ * plane is empty, so nothing is injected"* is an ANSWER, not a failure.
689
+ *
690
+ * So the read is recorded as a FACT OF ITS OWN — a receipt saying "at this phase entry, the plane was read,
691
+ * and this is what the read said" — instead of being inferred from what the read returned. An empty plane
692
+ * produces a receipt like any other, which is what makes the gate satisfiable in a new repo.
693
+ *
694
+ * ⚠ AND THE SUBJECT IS A RESERVED NAME. A memory entry's `source` is the shard's own PATH
695
+ * (`selectMemory` -> `loadMemoryIndex` -> `entry.source`), always a `.md` path under the memory plane, so
696
+ * `memory-read:attempt` is not a name any plane entry can hold. That is the property a collision would
697
+ * need to break the gate, and it is asserted in `tests/memory-feedback.spec.ts`.
698
+ */
699
+ const MEMORY_READ_SOURCE = "memory-read:attempt";
700
+ /**
701
+ * Read the counters. A missing or unreadable file is an empty book, never an error.
702
+ *
703
+ * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
704
+ * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
705
+ * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
706
+ * twice. A file that exists at the current path but does not parse stays an empty book, which is what
707
+ * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
708
+ */
709
+ function readFeedback(root, readFile = defaultRead$1) {
710
+ const current = readFile(join(root, FEEDBACK_FILE));
711
+ if (current !== null && current.trim() !== "") return parseBook(current);
712
+ const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE));
713
+ return legacy === null ? {} : parseBook(legacy);
714
+ }
715
+ /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
716
+ function parseBook(text) {
717
+ try {
718
+ const parsed = JSON.parse(text);
719
+ if (typeof parsed !== "object" || parsed === null) return {};
720
+ const book = {};
721
+ for (const [key, value] of Object.entries(parsed)) {
722
+ const record = value;
723
+ if (record === null || typeof record !== "object") continue;
724
+ book[key] = {
725
+ applied: Number.isFinite(record.applied) ? Number(record.applied) : 0,
726
+ contradicted: Number.isFinite(record.contradicted) ? Number(record.contradicted) : 0
727
+ };
728
+ }
729
+ return book;
730
+ } catch {
731
+ return {};
732
+ }
733
+ }
734
+ /** Read what a run recorded being shown. */
735
+ function readInjections(runDir, readFile = defaultRead$1) {
736
+ const text = readFile(join(runDir, INJECTIONS_FILE));
737
+ if (text === null || text.trim() === "") return [];
738
+ try {
739
+ const parsed = JSON.parse(text);
740
+ return Array.isArray(parsed) ? parsed : [];
741
+ } catch {
742
+ return [];
743
+ }
744
+ }
745
+ /**
746
+ * True for the read receipt, false for a shard the phase was shown.
747
+ *
748
+ * ⚠ THE DISCRIMINATOR IS THE SUBJECT **AND** THE SHAPE. The reserved subject alone would be enough if no
749
+ * plane entry could hold it, but a caller can hand-write this file, so a row is a receipt only when it also
750
+ * carries the receipt's own fields. A hand-edited file therefore cannot make a shard row look like a read,
751
+ * and `tests/memory-feedback.spec.ts` asserts the negative case.
752
+ *
753
+ * Accepts `unknown` rather than `InjectionRecord` because its callers hold JSON off disk (an array of
754
+ * whatever the file contains), and a type guard that could only be applied to a value already known to be
755
+ * well-shaped would not be worth having.
756
+ */
757
+ function isMemoryReadRecord(record) {
758
+ if (record === null || typeof record !== "object") return false;
759
+ const candidate = record;
760
+ return candidate.source === "memory-read:attempt" && typeof candidate.phase === "string" && typeof candidate.injected === "boolean" && typeof candidate.shards === "number" && typeof candidate.reason === "string";
761
+ }
762
+ /**
763
+ * The read receipts this run holds, in the file's own deterministic order.
764
+ *
765
+ * The reader the gate uses (see `hasMemoryRead` in `policy-globs.ts`): it is a NON-EMPTY answer even when
766
+ * every receipt says `injected: false`, because a receipt is evidence that the read RAN.
767
+ */
768
+ function readMemoryReads(runDir, readFile = defaultRead$1) {
769
+ return readInjections(runDir, readFile).filter(isMemoryReadRecord);
770
+ }
771
+ /**
772
+ * Record that a phase entry READ the memory plane, whatever the read returned.
773
+ *
774
+ * ⚠ THIS IS THE ATTEMPT, NOT THE RESULT, AND THE DIFFERENCE IS THE WHOLE POINT. `recordInjection` can only
775
+ * write a row when a shard was selected, so it is silent on an empty plane — and an empty plane is a
776
+ * legitimate state a fresh workspace is in, not a failure to record. A receipt written here says "the plane
777
+ * was read at this phase entry, and the read answered: <reason>", so `injected: false` is a SATISFIED read.
778
+ *
779
+ * ⚠ ONE RECEIPT PER PHASE, REPLACED (not appended). A phase is re-entered while it is still DRAFT — that is
780
+ * the ordinary path, not an edge case — so appending would turn one read per entry into an unbounded log
781
+ * and make the file grow with every reminder. The merge key is the phase, the newest read wins, and the
782
+ * rewrite is byte-identical when the same phase is read twice with the same answer, which is the
783
+ * determinism the lock receipts and the selection output already follow.
784
+ *
785
+ * ⚠ AND IT SHARES THE FILE WITH THE SHARD ROWS rather than living in a second sidecar: `recordInjection`
786
+ * preserves receipts when it rewrites (below), so the two writers cannot erase each other. A separate file
787
+ * would be a second answer to "what did this run read", which is the drift this repo keeps paying for.
788
+ */
789
+ function recordMemoryRead(runDir, phase, read, write = defaultWrite, readFile = defaultRead$1) {
790
+ const existing = readMemoryReads(runDir, readFile);
791
+ const byPhase = /* @__PURE__ */ new Map();
792
+ for (const record of existing) byPhase.set(record.phase, record);
793
+ byPhase.set(phase, {
794
+ source: MEMORY_READ_SOURCE,
795
+ title: phase,
796
+ phase,
797
+ score: 0,
798
+ injected: read.injected,
799
+ shards: read.shards,
800
+ reason: read.reason
801
+ });
802
+ const receipts = [...byPhase.values()].sort(compareRecords);
803
+ const shards = readInjections(runDir, readFile).filter((record) => !isMemoryReadRecord(record));
804
+ write(join(runDir, INJECTIONS_FILE), JSON.stringify(sortRecords([...shards, ...receipts]), null, 2) + "\n");
805
+ return receipts;
806
+ }
807
+ /**
808
+ * Record what the run was shown, MERGED by (source, title, phase).
809
+ *
810
+ * ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
811
+ * entries are selected again. Appending would count one decision as four, and the counters exist to be
812
+ * evidence. The highest score seen wins, since that is what the agent was most recently shown.
813
+ *
814
+ * ⚠ AND THE READ RECEIPTS SURVIVE THE REWRITE. This function owns the file, so a version of it that wrote
815
+ * only `merged` would delete the receipt `recordMemoryRead` had just written — the gate would then refuse a
816
+ * write in the same phase entry that satisfied it. The two kinds of row are therefore written together,
817
+ * sorted by the same key, and a receipt is never a candidate for the score merge (it carries no shard).
818
+ */
819
+ function recordInjection(runDir, entries, phase, write = defaultWrite, readFile = defaultRead$1) {
820
+ const existing = readInjections(runDir, readFile);
821
+ const byKey = /* @__PURE__ */ new Map();
822
+ for (const record of existing) {
823
+ if (isMemoryReadRecord(record)) continue;
824
+ byKey.set(keyOf(record), record);
825
+ }
826
+ for (const entry of entries) {
827
+ const candidate = {
828
+ source: entry.source,
829
+ title: entry.title,
830
+ phase,
831
+ score: entry.score
832
+ };
833
+ const key = keyOf(candidate);
834
+ const prior = byKey.get(key);
835
+ byKey.set(key, prior === void 0 || candidate.score > prior.score ? candidate : prior);
836
+ }
837
+ const receipts = existing.filter(isMemoryReadRecord);
838
+ const merged = sortRecords([...byKey.values(), ...receipts]);
839
+ write(join(runDir, INJECTIONS_FILE), JSON.stringify(merged, null, 2) + "\n");
840
+ return merged;
841
+ }
842
+ /**
843
+ * Settle a finished run against its own outcome and return the updated book.
844
+ *
845
+ * ⚠ THE OUTCOME SIGNAL, and it is deliberately modest: an entry injected for a phase that **locked** was
846
+ * APPLIED; an entry injected for a phase that had to come round again before it locked is CONTRADICTED — the
847
+ * memory did not carry the phase the first time. That is a real signal available from the run's own files,
848
+ * and it is not dressed up as more than that: no model was trained, and an entry is never deleted for losing.
849
+ */
850
+ function settleInjections(root, runDir, lockedPhases, write = defaultWrite, readFile = defaultRead$1) {
851
+ const injections = readInjections(runDir, readFile);
852
+ const book = readFeedback(root, readFile);
853
+ const locked = new Set(lockedPhases);
854
+ for (const record of injections) {
855
+ if (isMemoryReadRecord(record)) continue;
856
+ if (!locked.has(record.phase)) continue;
857
+ const counter = book[record.source] ?? {
858
+ applied: 0,
859
+ contradicted: 0
860
+ };
861
+ counter.applied += 1;
862
+ book[record.source] = counter;
863
+ }
864
+ const feedbackPath = join(root, FEEDBACK_FILE);
865
+ mkdirSync(dirname(feedbackPath), { recursive: true });
866
+ write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + "\n");
867
+ return book;
868
+ }
869
+ /**
870
+ * What the counters are worth in the ranking: `applied - contradicted`, clamped to one step.
871
+ *
872
+ * Clamped because a single long-lived entry should not be able to dominate the ranking forever, and because
873
+ * the counter is evidence about retrieval, not a verdict about the lesson.
874
+ */
875
+ function feedbackBonus(book, source) {
876
+ const counter = book[source];
877
+ if (counter === void 0) return 0;
878
+ const net = counter.applied - counter.contradicted;
879
+ return net === 0 ? 0 : net > 0 ? 1 : -1;
880
+ }
881
+ /**
882
+ * The file's ONE ordering, applied by both writers: phase, then source, then title.
883
+ *
884
+ * Both writers sort through this function rather than each carrying a copy, so two rows for one phase can
885
+ * never end up in an order that depends on which writer ran last — the property that keeps the file
886
+ * comparable between runs, and between a `recursive_phase` call and a `recursive_init` call.
887
+ */
888
+ function compareRecords(a, b) {
889
+ return a.phase.localeCompare(b.phase) || a.source.localeCompare(b.source) || a.title.localeCompare(b.title);
890
+ }
891
+ function sortRecords(records) {
892
+ return [...records].sort(compareRecords);
893
+ }
894
+ function keyOf(record) {
895
+ return record.phase + "\0" + record.source + "\0" + record.title;
896
+ }
897
+ function sortBook(book) {
898
+ const sorted = {};
899
+ for (const key of Object.keys(book).sort()) sorted[key] = book[key];
900
+ return sorted;
901
+ }
902
+ function defaultRead$1(path) {
903
+ try {
904
+ return readFileSync(path, "utf8");
905
+ } catch {
906
+ return null;
907
+ }
908
+ }
909
+ function defaultWrite(path, content) {
910
+ mkdirSync(dirname(path), { recursive: true });
911
+ writeFileSync(path, content, "utf8");
912
+ }
913
+ //#endregion
643
914
  //#region src/types.ts
644
915
  const PHASE_POSITIONS = [
645
916
  "absent",
@@ -1618,6 +1889,92 @@ function phaseOrderRule(target, ctx) {
1618
1889
  };
1619
1890
  }
1620
1891
  /**
1892
+ * THE MEMORY-READ GATE — the owner's rule, verbatim: *"the memory must be read before writing requirements.md"*.
1893
+ *
1894
+ * ⚠ THE ORDERING WAS INSTRUCTED AND NOT ENFORCED, and that is what this predicate changes. `recursive_phase`
1895
+ * calls `selectMemory` and hands the run what it found (`runtime.ts` `phaseRules`), so memory reaches the
1896
+ * agent at PHASE ENTRY — while `recursive_init` reads no memory at all. A run could therefore author
1897
+ * `00-requirements.md`, the artifact that defines what the whole run builds, BEFORE anything from
1898
+ * `.recursive/memory/` had reached it, and nothing refused that.
1899
+ *
1900
+ * ⚠ AND THE TRAP THAT DECIDES THE WHOLE DESIGN: `recordInjection` records the SHARDS, so when the memory
1901
+ * plane is EMPTY nothing is written — and a gate keyed on "a record exists" would then REFUSE FOREVER in a
1902
+ * fresh workspace. A repo with no memory is a legitimate, expected state (`selectMemory` answers
1903
+ * *"the memory plane is empty, so nothing is injected"*), so this predicate reads the READ RECEIPT rather
1904
+ * than the shards: `recordMemoryRead` writes one on every phase entry, with the selection's own reason and
1905
+ * `injected: false` when nothing matched. An empty plane therefore SATISFIES the gate, a never-entered
1906
+ * phase does not, and the two cases cannot be confused because one of them has a row.
1907
+ *
1908
+ * WHAT IT REFUSES, exactly: a write to a PHASE-0 artifact (`00-requirements.md` / `00-worktree.md` — the two
1909
+ * files `recursive_init` scaffolds, which share phase number 0 and which the phase-order rule already treats
1910
+ * as one phase) when this run holds no read receipt for phase 0, and the target is not already LOCKED.
1911
+ * Everything else abstains, so the rule costs a run nothing it should not pay:
1912
+ *
1913
+ * - a target that is not a DIRECT CHILD of this run's directory — an ordinary product file, and the run's
1914
+ * support files (`evidence/`, `scratch/`, `operations/`, a plain `<run>/notes.md`) — ABSTAINS. This is
1915
+ * what keeps the gate off the write path in general: `tests/strict-run-tree.spec.ts` walks every one of
1916
+ * those and asserts they stay writable.
1917
+ * - a target whose phase number is anything but `0` (a LATER phase, and an EARLIER one, which cannot exist
1918
+ * at phase 0) ABSTAINS: the ordering rules own those verdicts. A later phase in particular must keep
1919
+ * reporting `phase-order`, which is why this rule is declared LAST of the three that share these
1920
+ * patterns (file order within a specificity tier: locked-write, then phase-order, then this rule).
1921
+ * - a LOCKED target ABSTAINS: the locked-artifact rule owns it, and a completed run is not re-gated.
1922
+ * - a target that does not exist AT ALL does not abstain: `recursive_init` is not the only way to reach
1923
+ * phase 0, and a run whose requirements were deleted still has to read before it writes them again.
1924
+ * (In practice the refusal that fires there is the phase-order rule — an absent ACTIVE artifact still
1925
+ * makes the run phase 0 — so this rule is the second answer, not the first.)
1926
+ *
1927
+ * ⚠ RESUMED AND EXISTING RUNS. The gate is decided from the receipt alone — never from the artifact's text,
1928
+ * which a caller controls and could therefore forge — so the recovery is the same for every run, old or
1929
+ * new: read memory for this phase. `recursive_phase` is that call, it costs one call, and a run whose
1930
+ * `00-requirements.md` was written before this rule existed keeps every other guarantee it had (a completed
1931
+ * or locked phase 0 is untouched above; a run parked at phase 0 simply makes the read it never made). The
1932
+ * refusal SAYS that, because a refusal a caller cannot act on is the failure mode this rule must not have.
1933
+ *
1934
+ * ⚠ MODE SEMANTICS FOLLOW THE EXISTING CONTRACT EXACTLY (`enforcement.ts` `verdictFor`): a policy `deny`
1935
+ * under `strict` blocks, and under `advisory` becomes an `ask` that the live path coerces to an
1936
+ * allow-WITH-WARNING — never a silent allow, never a block. Like the phase-order rule, this is a `deny`
1937
+ * verdict, so the mode decides it and nothing here special-cases the mode.
1938
+ *
1939
+ * WHERE IT LIVES, AND WHY THIS LAYER RATHER THAN `lockArtifact`. The precedent in `lockArtifact` (the
1940
+ * phase-8 memory gate, `runtime.ts`) guards a LOCK: it is a state check on a run whose artifact already
1941
+ * exists, placed after quiescence and before lint. This rule guards a WRITE, and its whole subject is that
1942
+ * the write must not happen — a check at lock time would be too late by exactly the phase it is about, since
1943
+ * the requirements document has by then been authored and every later phase built on it. The repo already
1944
+ * has the write-side layer for ordering (`phase-order`), applied to the same write-tool family through
1945
+ * `attachPolicyPredicate`; a second copy of the check in `lockArtifact` would be the duplicate this repo has
1946
+ * ruled out, so there is exactly one, here.
1947
+ */
1948
+ function memoryReadRule(target, ctx) {
1949
+ if (!target || !ctx.runDir || !ctx.worktreeRoot) return null;
1950
+ const normalized = target.replace(/\\/g, "/");
1951
+ if (!normalized.endsWith(".md")) return null;
1952
+ const abs = resolveFrom(ctx.worktreeRoot, normalized);
1953
+ if (!abs) return null;
1954
+ const name = directChildName(abs, ctx.runDir);
1955
+ if (name === null) return null;
1956
+ if (phaseNumberForArtifact(name) !== "0") return null;
1957
+ if (getLockStatus(abs) === "LOCKED") return null;
1958
+ if (hasMemoryRead(ctx.runDir)) return null;
1959
+ return {
1960
+ verdict: "deny",
1961
+ detail: name + ": no memory read is recorded for phase 0 of this run - call recursive_phase, which reads the memory plane and records it, then write this artifact (an EMPTY memory plane satisfies this: the read is what is required, not a match)"
1962
+ };
1963
+ }
1964
+ /**
1965
+ * Read the run's read receipts and answer whether PHASE 0 has been read.
1966
+ *
1967
+ * ⚠ EITHER PHASE-0 ARTIFACT COUNTS, and that is a decision rather than a shortcut: `00-requirements.md` and
1968
+ * `00-worktree.md` share phase number 0, `currentPhaseArtifact` reports whichever of the two the directory
1969
+ * listing yields first, and `runtime.phaseRules` records the receipt under the artifact `getNextLegalPhase`
1970
+ * named — so keying the gate on the ONE name that happened to be active would make the verdict depend on
1971
+ * `readdirSync` order. `tests/strict-run-tree.spec.ts` asserts the two are one phase for the ordering rule;
1972
+ * this makes the gate agree with it. The phase NUMBER is the key, not the name, for exactly that reason.
1973
+ */
1974
+ function hasMemoryRead(runDir) {
1975
+ return readMemoryReads(runDir).some((receipt) => phaseNumberForArtifact(receipt.phase) === "0");
1976
+ }
1977
+ /**
1621
1978
  * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
1622
1979
  * data:
1623
1980
  *
@@ -1657,6 +2014,13 @@ function builtInToolPolicyRules() {
1657
2014
  label: "phase-order",
1658
2015
  predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
1659
2016
  });
2017
+ for (const name of WRITE_TOOL_NAMES) rules.push({
2018
+ pattern: name,
2019
+ verdict: "deny",
2020
+ reason: "memory read gate: memory must be read before the requirements artifact that defines the run is written",
2021
+ label: "memory-read",
2022
+ predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? memoryReadRule(policyTargetPath(args), ctx) : null
2023
+ });
1660
2024
  rules.push({
1661
2025
  pattern: "*",
1662
2026
  verdict: "allow",
@@ -1699,6 +2063,10 @@ function attachPolicyPredicate(rule) {
1699
2063
  ...rule,
1700
2064
  predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
1701
2065
  };
2066
+ if (rule.label === "memory-read") return {
2067
+ ...rule,
2068
+ predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? memoryReadRule(policyTargetPath(args), ctx) : null
2069
+ };
1702
2070
  if (WRITE_TOOL_NAMES.has(rule.pattern)) return {
1703
2071
  ...rule,
1704
2072
  predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null
@@ -2071,34 +2439,55 @@ const SECTION_MAP = {
2071
2439
  * field the plane's own linter already requires, so the artifact shape stays canonical.
2072
2440
  */
2073
2441
  const PHASE8_MEMORY_ARTIFACT = "08-memory-impact.md";
2442
+ /** The plane, repo-relative. The trailing slash is what every guard here tests for. */
2443
+ const MEMORY_PLANE_PREFIX = ".recursive/memory/";
2444
+ /** The canonical section the declaration goes in — present in every scaffolded phase-8 artifact. */
2445
+ const PHASE8_MEMORY_SECTION = "Affected Memory Docs";
2446
+ /**
2447
+ * Where a durable doc may be filed, and the `Type` the memory-plane linter requires for it.
2448
+ *
2449
+ * ⚠ EVERY `dir` IS THE REAL PLANE, `.recursive/memory/…`, AND NOT A `memory/…` RELATIVE FORM. Measured:
2450
+ * `bootstrap.ts` scaffolds the plane under `.recursive/`, `ts-lint.ts` lints it there, and the phase-8
2451
+ * artifact cites it there — while the training trigger's own write seam joins its `memory/…` paths onto
2452
+ * the workspace root, i.e. `<root>/memory/`, a directory that exists in no real workspace (the reader
2453
+ * side of that defect was fixed in `memory.ts`; the writer side is `runtime.ts`'s seam and is reported,
2454
+ * not edited). This table names the location a WRITE must land in, so it names the plane.
2455
+ *
2456
+ * ⚠ `skill` SHARES `pattern`'s Type ON PURPOSE: `/.recursive/memory/skills/patterns/` is where a
2457
+ * promoted skill lesson ships (`bootstrap.ts` scaffolds three docs there), and the linter's allowed
2458
+ * Types are `index|domain|pattern|incident|episode` — there is no `skill` Type to declare.
2459
+ */
2460
+ const MEMORY_DOC_LOCATIONS = {
2461
+ domain: {
2462
+ dir: ".recursive/memory/domains",
2463
+ type: "domain"
2464
+ },
2465
+ pattern: {
2466
+ dir: ".recursive/memory/patterns",
2467
+ type: "pattern"
2468
+ },
2469
+ incident: {
2470
+ dir: ".recursive/memory/incidents",
2471
+ type: "incident"
2472
+ },
2473
+ episode: {
2474
+ dir: ".recursive/memory/episodes",
2475
+ type: "episode"
2476
+ },
2477
+ skill: {
2478
+ dir: ".recursive/memory/skills/patterns",
2479
+ type: "pattern"
2480
+ }
2481
+ };
2482
+ /** The field that makes a doc THIS run's. It is the phase-8 gate's entire discriminator. */
2483
+ const MEMORY_PROVENANCE_FIELD = "Source-Runs";
2074
2484
  const PHASE8_MEMORY_WRITE_RULE = {
2075
2485
  artifact: PHASE8_MEMORY_ARTIFACT,
2076
- plane: ".recursive/memory/",
2077
- section: "Affected Memory Docs",
2078
- provenanceField: "Source-Runs",
2486
+ plane: MEMORY_PLANE_PREFIX,
2487
+ section: PHASE8_MEMORY_SECTION,
2488
+ provenanceField: MEMORY_PROVENANCE_FIELD,
2079
2489
  alwaysAvailable: ".recursive/memory/episodes/<run-id>.md",
2080
- kinds: Object.keys({
2081
- domain: {
2082
- dir: ".recursive/memory/domains",
2083
- type: "domain"
2084
- },
2085
- pattern: {
2086
- dir: ".recursive/memory/patterns",
2087
- type: "pattern"
2088
- },
2089
- incident: {
2090
- dir: ".recursive/memory/incidents",
2091
- type: "incident"
2092
- },
2093
- episode: {
2094
- dir: ".recursive/memory/episodes",
2095
- type: "episode"
2096
- },
2097
- skill: {
2098
- dir: ".recursive/memory/skills/patterns",
2099
- type: "pattern"
2100
- }
2101
- }),
2490
+ kinds: Object.keys(MEMORY_DOC_LOCATIONS),
2102
2491
  summary: "HARD: this run must have WRITTEN at least one doc under .recursive/memory/ before 08-memory-impact.md locks — .recursive/memory/episodes/<run-id>.md is always available — declared by path under `## Affected Memory Docs` and carrying `Source-Runs: <this-run-id>`; citing a shard this run did not write does not count.",
2103
2492
  instruction: "HARD REQUIREMENT, CHECKED AT LOCK: before 08-memory-impact.md locks, this run must have WRITTEN at least one doc under .recursive/memory/ and declared that path under `## Affected Memory Docs`. A declared path counts ONLY when the doc on disk carries `Source-Runs` naming THIS run, because that is what separates \"the run wrote its memory\" from \"the run cited someone else's\". Render the doc with the metadata the memory-plane lint requires (Type, Status, Scope, Owns-Paths, Watch-Paths, Source-Runs, Validated-At-Commit, Last-Validated, Tags): .recursive/memory/episodes/<run-id>.md is always available for a run-local lesson, `.recursive/memory/domains/`, `.recursive/memory/patterns/` and `.recursive/memory/incidents/` hold generalized knowledge, and `.recursive/memory/skills/patterns/` is where a promoted skill lesson belongs. A doc missing a required field, or carrying a Type/Status the plane lint rejects, FAILS the memory plane — write it in the canonical shape. The written path enters the run diff under .recursive/memory/, which phase 8 OWNS in its Worktree Diff Audit and Requirement Completion Status."
2104
2493
  };
@@ -6237,172 +6626,6 @@ function reviewBundleDir(root, runId) {
6237
6626
  return join(resolveUnderRoot$2(root, ".recursive/run/" + runId), "evidence", "review-bundles");
6238
6627
  }
6239
6628
  //#endregion
6240
- //#region src/memory-feedback.ts
6241
- /**
6242
- * Memory feedback (FU-13 P3) — which entries were injected into which run, and how that run turned out.
6243
- *
6244
- * ⚠ WHAT THIS IS, HONESTLY LABELLED: a COUNTER, not learning. `TRAINING.md` puts model training (the
6245
- * reference's 58 KB GRPO script) out of scope for a workflow plugin. What is in scope is the loop that makes
6246
- * retrieval improve with evidence: record what was injected, settle it against the run's outcome, and let the
6247
- * selector prefer what has held up.
6248
- *
6249
- * ⚠ AND WHY THE COUNTERS LIVE IN A SIDECAR, NOT IN THE SHARDS. The memory plane's documents are
6250
- * human-authored: a person writes the lesson. Rewriting one to bump a number would be the silent-overwrite
6251
- * defect this repo already produced once (the closeout writing over phase documents), applied to the memory
6252
- * plane. So the counters live in `.recursive/memory/.feedback.json`, which is machine-owned and disposable —
6253
- * delete it and you lose ranking evidence, not knowledge.
6254
- *
6255
- * ⚠ NO TIMESTAMPS ANYWHERE: two runs with the same inputs must produce identical files, which is the same
6256
- * discipline the lock receipts, the closeout receipts and the selection output all follow.
6257
- */
6258
- /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
6259
- const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
6260
- /**
6261
- * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
6262
- *
6263
- * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
6264
- * not the plugin's to discard, and without the fallback the first settle after the move would start the
6265
- * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
6266
- * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
6267
- * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
6268
- * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
6269
- * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
6270
- * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
6271
- * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
6272
- * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
6273
- */
6274
- const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
6275
- /** Where a run records what it was shown. */
6276
- const INJECTIONS_FILE = "memory-injections.json";
6277
- /**
6278
- * Read the counters. A missing or unreadable file is an empty book, never an error.
6279
- *
6280
- * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
6281
- * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
6282
- * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
6283
- * twice. A file that exists at the current path but does not parse stays an empty book, which is what
6284
- * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
6285
- */
6286
- function readFeedback(root, readFile = defaultRead$1) {
6287
- const current = readFile(join(root, FEEDBACK_FILE));
6288
- if (current !== null && current.trim() !== "") return parseBook(current);
6289
- const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE));
6290
- return legacy === null ? {} : parseBook(legacy);
6291
- }
6292
- /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
6293
- function parseBook(text) {
6294
- try {
6295
- const parsed = JSON.parse(text);
6296
- if (typeof parsed !== "object" || parsed === null) return {};
6297
- const book = {};
6298
- for (const [key, value] of Object.entries(parsed)) {
6299
- const record = value;
6300
- if (record === null || typeof record !== "object") continue;
6301
- book[key] = {
6302
- applied: Number.isFinite(record.applied) ? Number(record.applied) : 0,
6303
- contradicted: Number.isFinite(record.contradicted) ? Number(record.contradicted) : 0
6304
- };
6305
- }
6306
- return book;
6307
- } catch {
6308
- return {};
6309
- }
6310
- }
6311
- /** Read what a run recorded being shown. */
6312
- function readInjections(runDir, readFile = defaultRead$1) {
6313
- const text = readFile(join(runDir, INJECTIONS_FILE));
6314
- if (text === null || text.trim() === "") return [];
6315
- try {
6316
- const parsed = JSON.parse(text);
6317
- return Array.isArray(parsed) ? parsed : [];
6318
- } catch {
6319
- return [];
6320
- }
6321
- }
6322
- /**
6323
- * Record what the run was shown, MERGED by (source, title, phase).
6324
- *
6325
- * ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
6326
- * entries are selected again. Appending would count one decision as four, and the counters exist to be
6327
- * evidence. The highest score seen wins, since that is what the agent was most recently shown.
6328
- */
6329
- function recordInjection(runDir, entries, phase, write = defaultWrite, readFile = defaultRead$1) {
6330
- const existing = readInjections(runDir, readFile);
6331
- const byKey = /* @__PURE__ */ new Map();
6332
- for (const record of existing) byKey.set(keyOf(record), record);
6333
- for (const entry of entries) {
6334
- const candidate = {
6335
- source: entry.source,
6336
- title: entry.title,
6337
- phase,
6338
- score: entry.score
6339
- };
6340
- const key = keyOf(candidate);
6341
- const prior = byKey.get(key);
6342
- byKey.set(key, prior === void 0 || candidate.score > prior.score ? candidate : prior);
6343
- }
6344
- const merged = [...byKey.values()].sort((a, b) => a.phase.localeCompare(b.phase) || a.source.localeCompare(b.source) || a.title.localeCompare(b.title));
6345
- write(join(runDir, INJECTIONS_FILE), JSON.stringify(merged, null, 2) + "\n");
6346
- return merged;
6347
- }
6348
- /**
6349
- * Settle a finished run against its own outcome and return the updated book.
6350
- *
6351
- * ⚠ THE OUTCOME SIGNAL, and it is deliberately modest: an entry injected for a phase that **locked** was
6352
- * APPLIED; an entry injected for a phase that had to come round again before it locked is CONTRADICTED — the
6353
- * memory did not carry the phase the first time. That is a real signal available from the run's own files,
6354
- * and it is not dressed up as more than that: no model was trained, and an entry is never deleted for losing.
6355
- */
6356
- function settleInjections(root, runDir, lockedPhases, write = defaultWrite, readFile = defaultRead$1) {
6357
- const injections = readInjections(runDir, readFile);
6358
- const book = readFeedback(root, readFile);
6359
- const locked = new Set(lockedPhases);
6360
- for (const record of injections) {
6361
- if (!locked.has(record.phase)) continue;
6362
- const counter = book[record.source] ?? {
6363
- applied: 0,
6364
- contradicted: 0
6365
- };
6366
- counter.applied += 1;
6367
- book[record.source] = counter;
6368
- }
6369
- const feedbackPath = join(root, FEEDBACK_FILE);
6370
- mkdirSync(dirname(feedbackPath), { recursive: true });
6371
- write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + "\n");
6372
- return book;
6373
- }
6374
- /**
6375
- * What the counters are worth in the ranking: `applied - contradicted`, clamped to one step.
6376
- *
6377
- * Clamped because a single long-lived entry should not be able to dominate the ranking forever, and because
6378
- * the counter is evidence about retrieval, not a verdict about the lesson.
6379
- */
6380
- function feedbackBonus(book, source) {
6381
- const counter = book[source];
6382
- if (counter === void 0) return 0;
6383
- const net = counter.applied - counter.contradicted;
6384
- return net === 0 ? 0 : net > 0 ? 1 : -1;
6385
- }
6386
- function keyOf(record) {
6387
- return record.phase + "\0" + record.source + "\0" + record.title;
6388
- }
6389
- function sortBook(book) {
6390
- const sorted = {};
6391
- for (const key of Object.keys(book).sort()) sorted[key] = book[key];
6392
- return sorted;
6393
- }
6394
- function defaultRead$1(path) {
6395
- try {
6396
- return readFileSync(path, "utf8");
6397
- } catch {
6398
- return null;
6399
- }
6400
- }
6401
- function defaultWrite(path, content) {
6402
- mkdirSync(dirname(path), { recursive: true });
6403
- writeFileSync(path, content, "utf8");
6404
- }
6405
- //#endregion
6406
6629
  //#region src/memory.ts
6407
6630
  /**
6408
6631
  * T14 — retrieve prior-run memory into the review bundle, so the recursion COMPOUNDS.
@@ -6529,10 +6752,11 @@ function readMemoryEntries(readFile, listFiles, kinds = MEMORY_KINDS) {
6529
6752
  }
6530
6753
  return entries;
6531
6754
  }
6532
- /** Render the retrieved memory for a review bundle: sections a reviewer can cite by title. */
6533
- function renderMemorySection(entries) {
6534
- if (entries.length === 0) return "No prior-run memory matched this review. Do not assume the absence is conclusive: memory is retrieved by relevance to the artifact and phase, not by recency.";
6535
- const lines = ["Prior-run memory relevant to this review (cite by title if you rely on it):"];
6755
+ /** Render the retrieved memory: sections a reader can cite by title. See {@link MemoryRenderContext}. */
6756
+ function renderMemorySection(entries, context = "review") {
6757
+ const where = context === "phase" ? "this phase" : "this review";
6758
+ if (entries.length === 0) return "No prior-run memory matched " + where + ". Do not assume the absence is conclusive: memory is retrieved by relevance to the artifact and phase, not by recency.";
6759
+ const lines = [context === "phase" ? "Prior-run memory injected for this phase (rely on it, and cite by title when you use it):" : "Prior-run memory relevant to this review (cite by title if you rely on it):"];
6536
6760
  for (const entry of entries) {
6537
6761
  lines.push("");
6538
6762
  lines.push("### [" + entry.kind + "] " + entry.title);
@@ -7173,6 +7397,149 @@ function renderMemoryMetadata(input) {
7173
7397
  if (input.parent !== void 0 && input.parent.trim() !== "") lines.push("Parent: `" + input.parent.replace(/`/g, "'") + "`");
7174
7398
  return lines.join("\n") + "\n";
7175
7399
  }
7400
+ function unquoteMemoryValue(value) {
7401
+ const trimmed = value.trim();
7402
+ for (const quote of [
7403
+ "`",
7404
+ "\"",
7405
+ "'"
7406
+ ]) if (trimmed.length >= 2 && trimmed.startsWith(quote) && trimmed.endsWith(quote)) return trimmed.slice(1, -1).trim();
7407
+ return trimmed;
7408
+ }
7409
+ /**
7410
+ * The values of a list field (`Source-Runs:`), inline or as bullets.
7411
+ *
7412
+ * ⚠ THE BLOCK ENDS AT THE FIRST BLANK LINE, HEADING OR NEW FIELD, and that strictness is the point:
7413
+ * the alternative is a parser that reads an unrelated bullet list further down the document as
7414
+ * provenance — and provenance is the one thing here that must never be guessed.
7415
+ */
7416
+ function parseMemoryListField(content, fieldName) {
7417
+ const fieldRe = new RegExp("^[ \\t]*(?:[-*][ \\t]+)?" + fieldName + ":[ \\t]*(.*)$");
7418
+ const values = [];
7419
+ let inField = false;
7420
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
7421
+ const field = fieldRe.exec(line);
7422
+ if (field !== null) {
7423
+ inField = true;
7424
+ const inline = unquoteMemoryValue(field[1]);
7425
+ if (inline !== "") values.push(inline);
7426
+ continue;
7427
+ }
7428
+ if (!inField) continue;
7429
+ const item = /^[ \t]*[-*][ \t]+(.+)$/.exec(line);
7430
+ if (item !== null) {
7431
+ const value = unquoteMemoryValue(item[1]);
7432
+ if (value !== "") values.push(value);
7433
+ continue;
7434
+ }
7435
+ break;
7436
+ }
7437
+ return values;
7438
+ }
7439
+ /** The runs a doc's `Source-Runs` names. EXACT matches: `run-1` is not `run-10`. */
7440
+ function memoryDocProvenance(content) {
7441
+ return parseMemoryListField(content, MEMORY_PROVENANCE_FIELD);
7442
+ }
7443
+ function readTextOrNull(path) {
7444
+ try {
7445
+ return readFileSync(path, "utf8");
7446
+ } catch {
7447
+ return null;
7448
+ }
7449
+ }
7450
+ /** Every `.recursive/memory/**` path a text declares, in the absolute or repo-relative spelling. */
7451
+ function phase8MemoryRefs(text) {
7452
+ const found = /* @__PURE__ */ new Set();
7453
+ const absolute = /(?:^|[\s(`'"<[])\/?\.recursive\/memory\/[A-Za-z0-9._@\-/]*\.md/g;
7454
+ const relative = /(?:^|[\s(`'"<[])(memory\/(?:domains|patterns|incidents|episodes|training|skills|archive)\/[A-Za-z0-9._@\-/]*\.md)/g;
7455
+ let match;
7456
+ while ((match = absolute.exec(text)) !== null) found.add(match[0].replace(/^[\s(`'"<[]/, "").replace(/^\/+/, ""));
7457
+ while ((match = relative.exec(text)) !== null) found.add(MEMORY_PLANE_PREFIX + match[1].slice(7));
7458
+ return [...found].sort();
7459
+ }
7460
+ /**
7461
+ * T40 — THE CHECKABLE FACT BEHIND "the run wrote its durable memory".
7462
+ *
7463
+ * Three conditions, and each one exists because of a way the claim could be made without the work
7464
+ * being done: the artifact DECLARES a path under the plane (not prose about memory in general); the
7465
+ * path EXISTS (a declaration is not a write); and the doc on disk carries `Source-Runs` naming THIS
7466
+ * run (a shard the run merely read, or one an earlier run wrote, is not this run's memory).
7467
+ *
7468
+ * ⚠ WHAT IT CANNOT TELL, stated rather than hidden: WHEN the doc was written. A run that wrote a
7469
+ * memory doc before phase 8 and cites it here passes — and the phase 6/7 baselines deny memory-plane
7470
+ * writes outright (`phaseBaselineRules`), so within this workflow the only phase that can produce
7471
+ * such a doc is 8. The check is about the FACT existing at lock time, not about the clock.
7472
+ */
7473
+ function phase8MemoryEvidence(root, runId, artifactText, readText = readTextOrNull) {
7474
+ const declared = phase8MemoryRefs(artifactText);
7475
+ if (declared.length === 0) return {
7476
+ ok: false,
7477
+ declared,
7478
+ existing: [],
7479
+ written: [],
7480
+ reason: "the artifact declares no path under .recursive/memory/ at all"
7481
+ };
7482
+ const existing = [];
7483
+ const written = [];
7484
+ for (const path of declared) {
7485
+ const text = readText(join(root, path));
7486
+ if (text === null) continue;
7487
+ existing.push(path);
7488
+ if (memoryDocProvenance(text).includes(runId)) written.push(path);
7489
+ }
7490
+ if (written.length > 0) return {
7491
+ ok: true,
7492
+ declared,
7493
+ existing,
7494
+ written,
7495
+ reason: written.length + " declared memory doc(s) carry Source-Runs naming " + runId + ": " + written.join(", ")
7496
+ };
7497
+ if (existing.length === 0) return {
7498
+ ok: false,
7499
+ declared,
7500
+ existing,
7501
+ written,
7502
+ reason: "the artifact declares " + declared.length + " path(s) under .recursive/memory/ (" + declared.join(", ") + "), but none of them exists, so no memory doc was written"
7503
+ };
7504
+ return {
7505
+ ok: false,
7506
+ declared,
7507
+ existing,
7508
+ written,
7509
+ reason: "the artifact declares " + existing.length + " existing memory doc(s) (" + existing.join(", ") + "), but none carries Source-Runs naming run " + runId + " — citing a shard this run did not write is not a write"
7510
+ };
7511
+ }
7512
+ /**
7513
+ * T40 — THE REFUSAL, as a sentence, or null when the run may lock.
7514
+ *
7515
+ * ⚠ THE MESSAGE NAMES THE REMEDY, not only the fault. A refusal that says "no memory doc" leaves the
7516
+ * agent to guess a format it cannot guess (nine fields, five allowed Types, a provenance list), which
7517
+ * is how the step became a ticked box in the first place.
7518
+ */
7519
+ function phase8MemoryRefusal(root, runId, artifactText, readText = readTextOrNull) {
7520
+ const evidence = phase8MemoryEvidence(root, runId, artifactText, readText);
7521
+ if (evidence.ok) return null;
7522
+ return "locking 08-memory-impact.md requires this run to have WRITTEN a doc under .recursive/memory/: " + evidence.reason + ". Write one (memory/episodes/" + runId + ".md is always available), declare its path under `## Affected Memory Docs`, and give the doc `Source-Runs: " + runId + "` — then retry the lock.";
7523
+ }
7524
+ /**
7525
+ * T40 — THE LOCK-TIME ENTRY POINT: the refusal for the artifact being locked, or null.
7526
+ *
7527
+ * ⚠ THIS EXISTS SO THE GATE IS ONE LINE AT ITS CALL SITE. The decision (declared → exists → carries
7528
+ * this run's provenance) belongs in this module with the write surface that produces it; `lockArtifact`
7529
+ * should have to say only WHICH artifact it is locking, not how a memory doc is recognised. A caller
7530
+ * that has to reproduce the rule would be a second copy of it.
7531
+ *
7532
+ * ⚠ AND A MISSING ARTIFACT IS NOT THIS GATE'S REFUSAL. `lockArtifact` already refuses an absent
7533
+ * artifact before any gate can run, and returning a memory refusal for a file that does not exist
7534
+ * would replace "the artifact is missing" with a sentence about memory — a misleading diagnosis in
7535
+ * exchange for nothing.
7536
+ */
7537
+ function phase8MemoryLockRefusal(root, runId, artifact) {
7538
+ if (artifact !== PHASE8_ARTIFACT) return null;
7539
+ const artifactText = readTextOrNull(join(root, ".recursive", "run", runId, PHASE8_ARTIFACT));
7540
+ if (artifactText === null) return null;
7541
+ return phase8MemoryRefusal(root, runId, artifactText);
7542
+ }
7176
7543
  //#endregion
7177
7544
  //#region src/run-spec.ts
7178
7545
  /** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
@@ -8622,7 +8989,8 @@ function renderStableContract(config = DEFAULT_ENFORCEMENT) {
8622
8989
  "- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.",
8623
8990
  "- Phase order binds WRITES as well as locks: only the ACTIVE phase (the lowest-numbered artifact not yet LOCKED) may be written; a write to a LATER phase artifact is denied/asked. Run support files (evidence/, scratch/, addenda/, subagents/, operations/) are not phases.",
8624
8991
  "- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.",
8625
- "- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another)."
8992
+ "- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another).",
8993
+ "- Memory is READ AT PHASE ENTRY by recursive_phase: it returns what the memory plane holds for this run and phase (the selected shards, or `memoryReason` saying why nothing was injected). Read it when entering a phase, rely on what it gives you, and cite a shard by title wherever you act on it. An EMPTY plane is a normal result, not a failure: \"the memory plane is empty, so nothing is injected\" is an answer, and the phase proceeds with what the run itself knows."
8626
8994
  ].join("\n");
8627
8995
  }
8628
8996
  /**
@@ -11583,11 +11951,16 @@ var RecursiveRuntime = class extends Service {
11583
11951
  title: shard.entry.title,
11584
11952
  score: shard.score
11585
11953
  })), phase);
11954
+ recordMemoryRead(resolved.runDir, phase, {
11955
+ injected: selection.injected,
11956
+ shards: selection.shards.length,
11957
+ reason: selection.reason
11958
+ });
11586
11959
  return {
11587
11960
  runId: resolved.runId,
11588
11961
  phase,
11589
11962
  ...phaseRulesFor(phase),
11590
- memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry)) : "",
11963
+ memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry), "phase") : "",
11591
11964
  memoryReason: selection.reason,
11592
11965
  ...pending === null ? {} : { ask: {
11593
11966
  gate: pending,
@@ -11804,6 +12177,16 @@ var RecursiveRuntime = class extends Service {
11804
12177
  }
11805
12178
  const inFlight = pendingWork(runDir);
11806
12179
  if (inFlight.length > 0) throw new Error(toolError("PENDING_WORK", inFlight.map((p) => p.detail).join("; ")));
12180
+ const memoryRefusal = phase8MemoryLockRefusal(root, runId, artifact);
12181
+ if (memoryRefusal !== null) {
12182
+ try {
12183
+ this.blockRunToGoal(agent, runId, {
12184
+ code: "phase8-memory-missing",
12185
+ message: memoryRefusal
12186
+ });
12187
+ } catch {}
12188
+ throw new Error(memoryRefusal);
12189
+ }
11807
12190
  const lint = await this.lintArtifact(runId, artifact, agent);
11808
12191
  if (!lint.passed) throw new Error("Artifact " + artifact + " does not meet the phase standard, so it was not locked: " + lint.errors.join("; "));
11809
12192
  let content = readFileSync(artifactPath, "utf8");
@@ -12654,6 +13037,22 @@ function createRecursiveWorktreeTool(recursive) {
12654
13037
  * reminder, so the agent can re-ask for the rules without re-injecting them on
12655
13038
  * every step. Returns { error } when no active phase is found.
12656
13039
  *
13040
+ * ⚠ AND IT IS WHERE PRIOR-RUN MEMORY ARRIVES — say so in the DESCRIPTION, which is the only surface a
13041
+ * model reads before choosing a tool. The description used to promise rules and instructions only, so
13042
+ * nothing in the tool list gave a caller a reason to expect memory here (the owner's own report: *"the
13043
+ * memory must be read before writing requirements.md"*, and *"recursive_phase should also read memories"*).
13044
+ * The read WAS already happening — `phaseRules` calls `selectMemory` and returns the section plus its
13045
+ * reason — so the defect was that the contract was SILENT about it, not that the mechanism was absent.
13046
+ * Every sentence below is a claim about what this call actually returns: the payload's `memory` and
13047
+ * `memoryReason` fields come from `selectMemory`, `runId`/`phase` from the resolved run, `requiredSections`
13048
+ * / `audited` / `tdd` / `qa` / `memoryWrite` from `phaseRulesFor`, and `ask` from `pendingGateFor`. A
13049
+ * description that advertised a field the payload does not carry would be worse than the silence it fixes.
13050
+ *
13051
+ * ⚠ AND AN EMPTY PLANE IS SAID TO BE NORMAL, because that is the honest reading and the one a model needs:
13052
+ * `selectMemory` answers *"the memory plane is empty, so nothing is injected"*, which is a RESULT — the
13053
+ * plane was read and had nothing to say — not a failure of the call and not a reason to retry it. Without
13054
+ * that sentence an agent seeing an empty section could reasonably conclude the read had not happened.
13055
+ *
12657
13056
  * A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
12658
13057
  * different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
12659
13058
  * answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
@@ -12665,7 +13064,7 @@ function createRecursiveWorktreeTool(recursive) {
12665
13064
  function createRecursivePhaseTool(recursive) {
12666
13065
  return defineTool({
12667
13066
  name: "recursive_phase",
12668
- description: "Return the lint rules + instructions for the current recursive-mode phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.",
13067
+ description: "Enter the current recursive-mode phase: returns that phase's lint rules and instructions (required sections, gates, TDD/QA notes) AND the prior-run memory selected for this run and phase. `memory` carries the shards to use, each titled so it can be cited, and `memoryReason` says why nothing was injected when it is empty — an EMPTY memory plane is a normal result of a read, never a failure, so do not retry because of it. Nothing from the memory plane reaches a run before this call, so MAKE IT ONCE WHEN ENTERING A PHASE and before authoring that phase's artifact (`00-requirements.md` included: a write to it is refused until a read is recorded for the run). The same rules are also auto-injected once per phase transition.",
12669
13068
  parameters: { runId: {
12670
13069
  type: "string",
12671
13070
  description: "Optional run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: " + RUN_ID_RULE + ". Omit it for the latest run by mtime."