@try-works/dsh-recursive-mode 0.6.1 → 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.
@@ -90,8 +90,13 @@ export declare const DEFAULT_ENFORCEMENT: EnforcementConfig;
90
90
  * active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
91
91
  * locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
92
92
  * because the guard log has to tell the owner which of the two the agent attempted.
93
+ *
94
+ * `'memory-read'` is the third WRITE-side rule and the owner's other requirement, verbatim: *the memory must
95
+ * be read before writing requirements.md*. It rides the same write-tool family as `'phase-order'` (see
96
+ * `memoryReadRule` in `policy-globs.ts`) and is listed here because this union is what the guard's decision
97
+ * record is typed by — a label the record's type does not know is a label the log cannot honestly carry.
93
98
  */
94
- export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
99
+ export type GuardRule = 'lock-order' | 'phase-order' | 'memory-read' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
95
100
  /** T15: the transition gate's verdict as attached to a decision (advisory only). */
96
101
  export interface GuardTransition {
97
102
  passed: boolean;
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
@@ -6258,172 +6626,6 @@ function reviewBundleDir(root, runId) {
6258
6626
  return join(resolveUnderRoot$2(root, ".recursive/run/" + runId), "evidence", "review-bundles");
6259
6627
  }
6260
6628
  //#endregion
6261
- //#region src/memory-feedback.ts
6262
- /**
6263
- * Memory feedback (FU-13 P3) — which entries were injected into which run, and how that run turned out.
6264
- *
6265
- * ⚠ WHAT THIS IS, HONESTLY LABELLED: a COUNTER, not learning. `TRAINING.md` puts model training (the
6266
- * reference's 58 KB GRPO script) out of scope for a workflow plugin. What is in scope is the loop that makes
6267
- * retrieval improve with evidence: record what was injected, settle it against the run's outcome, and let the
6268
- * selector prefer what has held up.
6269
- *
6270
- * ⚠ AND WHY THE COUNTERS LIVE IN A SIDECAR, NOT IN THE SHARDS. The memory plane's documents are
6271
- * human-authored: a person writes the lesson. Rewriting one to bump a number would be the silent-overwrite
6272
- * defect this repo already produced once (the closeout writing over phase documents), applied to the memory
6273
- * plane. So the counters live in `.recursive/memory/.feedback.json`, which is machine-owned and disposable —
6274
- * delete it and you lose ranking evidence, not knowledge.
6275
- *
6276
- * ⚠ NO TIMESTAMPS ANYWHERE: two runs with the same inputs must produce identical files, which is the same
6277
- * discipline the lock receipts, the closeout receipts and the selection output all follow.
6278
- */
6279
- /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
6280
- const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
6281
- /**
6282
- * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
6283
- *
6284
- * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
6285
- * not the plugin's to discard, and without the fallback the first settle after the move would start the
6286
- * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
6287
- * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
6288
- * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
6289
- * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
6290
- * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
6291
- * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
6292
- * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
6293
- * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
6294
- */
6295
- const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
6296
- /** Where a run records what it was shown. */
6297
- const INJECTIONS_FILE = "memory-injections.json";
6298
- /**
6299
- * Read the counters. A missing or unreadable file is an empty book, never an error.
6300
- *
6301
- * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
6302
- * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
6303
- * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
6304
- * twice. A file that exists at the current path but does not parse stays an empty book, which is what
6305
- * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
6306
- */
6307
- function readFeedback(root, readFile = defaultRead$1) {
6308
- const current = readFile(join(root, FEEDBACK_FILE));
6309
- if (current !== null && current.trim() !== "") return parseBook(current);
6310
- const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE));
6311
- return legacy === null ? {} : parseBook(legacy);
6312
- }
6313
- /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
6314
- function parseBook(text) {
6315
- try {
6316
- const parsed = JSON.parse(text);
6317
- if (typeof parsed !== "object" || parsed === null) return {};
6318
- const book = {};
6319
- for (const [key, value] of Object.entries(parsed)) {
6320
- const record = value;
6321
- if (record === null || typeof record !== "object") continue;
6322
- book[key] = {
6323
- applied: Number.isFinite(record.applied) ? Number(record.applied) : 0,
6324
- contradicted: Number.isFinite(record.contradicted) ? Number(record.contradicted) : 0
6325
- };
6326
- }
6327
- return book;
6328
- } catch {
6329
- return {};
6330
- }
6331
- }
6332
- /** Read what a run recorded being shown. */
6333
- function readInjections(runDir, readFile = defaultRead$1) {
6334
- const text = readFile(join(runDir, INJECTIONS_FILE));
6335
- if (text === null || text.trim() === "") return [];
6336
- try {
6337
- const parsed = JSON.parse(text);
6338
- return Array.isArray(parsed) ? parsed : [];
6339
- } catch {
6340
- return [];
6341
- }
6342
- }
6343
- /**
6344
- * Record what the run was shown, MERGED by (source, title, phase).
6345
- *
6346
- * ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
6347
- * entries are selected again. Appending would count one decision as four, and the counters exist to be
6348
- * evidence. The highest score seen wins, since that is what the agent was most recently shown.
6349
- */
6350
- function recordInjection(runDir, entries, phase, write = defaultWrite, readFile = defaultRead$1) {
6351
- const existing = readInjections(runDir, readFile);
6352
- const byKey = /* @__PURE__ */ new Map();
6353
- for (const record of existing) byKey.set(keyOf(record), record);
6354
- for (const entry of entries) {
6355
- const candidate = {
6356
- source: entry.source,
6357
- title: entry.title,
6358
- phase,
6359
- score: entry.score
6360
- };
6361
- const key = keyOf(candidate);
6362
- const prior = byKey.get(key);
6363
- byKey.set(key, prior === void 0 || candidate.score > prior.score ? candidate : prior);
6364
- }
6365
- const merged = [...byKey.values()].sort((a, b) => a.phase.localeCompare(b.phase) || a.source.localeCompare(b.source) || a.title.localeCompare(b.title));
6366
- write(join(runDir, INJECTIONS_FILE), JSON.stringify(merged, null, 2) + "\n");
6367
- return merged;
6368
- }
6369
- /**
6370
- * Settle a finished run against its own outcome and return the updated book.
6371
- *
6372
- * ⚠ THE OUTCOME SIGNAL, and it is deliberately modest: an entry injected for a phase that **locked** was
6373
- * APPLIED; an entry injected for a phase that had to come round again before it locked is CONTRADICTED — the
6374
- * memory did not carry the phase the first time. That is a real signal available from the run's own files,
6375
- * and it is not dressed up as more than that: no model was trained, and an entry is never deleted for losing.
6376
- */
6377
- function settleInjections(root, runDir, lockedPhases, write = defaultWrite, readFile = defaultRead$1) {
6378
- const injections = readInjections(runDir, readFile);
6379
- const book = readFeedback(root, readFile);
6380
- const locked = new Set(lockedPhases);
6381
- for (const record of injections) {
6382
- if (!locked.has(record.phase)) continue;
6383
- const counter = book[record.source] ?? {
6384
- applied: 0,
6385
- contradicted: 0
6386
- };
6387
- counter.applied += 1;
6388
- book[record.source] = counter;
6389
- }
6390
- const feedbackPath = join(root, FEEDBACK_FILE);
6391
- mkdirSync(dirname(feedbackPath), { recursive: true });
6392
- write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + "\n");
6393
- return book;
6394
- }
6395
- /**
6396
- * What the counters are worth in the ranking: `applied - contradicted`, clamped to one step.
6397
- *
6398
- * Clamped because a single long-lived entry should not be able to dominate the ranking forever, and because
6399
- * the counter is evidence about retrieval, not a verdict about the lesson.
6400
- */
6401
- function feedbackBonus(book, source) {
6402
- const counter = book[source];
6403
- if (counter === void 0) return 0;
6404
- const net = counter.applied - counter.contradicted;
6405
- return net === 0 ? 0 : net > 0 ? 1 : -1;
6406
- }
6407
- function keyOf(record) {
6408
- return record.phase + "\0" + record.source + "\0" + record.title;
6409
- }
6410
- function sortBook(book) {
6411
- const sorted = {};
6412
- for (const key of Object.keys(book).sort()) sorted[key] = book[key];
6413
- return sorted;
6414
- }
6415
- function defaultRead$1(path) {
6416
- try {
6417
- return readFileSync(path, "utf8");
6418
- } catch {
6419
- return null;
6420
- }
6421
- }
6422
- function defaultWrite(path, content) {
6423
- mkdirSync(dirname(path), { recursive: true });
6424
- writeFileSync(path, content, "utf8");
6425
- }
6426
- //#endregion
6427
6629
  //#region src/memory.ts
6428
6630
  /**
6429
6631
  * T14 — retrieve prior-run memory into the review bundle, so the recursion COMPOUNDS.
@@ -6550,10 +6752,11 @@ function readMemoryEntries(readFile, listFiles, kinds = MEMORY_KINDS) {
6550
6752
  }
6551
6753
  return entries;
6552
6754
  }
6553
- /** Render the retrieved memory for a review bundle: sections a reviewer can cite by title. */
6554
- function renderMemorySection(entries) {
6555
- 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.";
6556
- 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):"];
6557
6760
  for (const entry of entries) {
6558
6761
  lines.push("");
6559
6762
  lines.push("### [" + entry.kind + "] " + entry.title);
@@ -8786,7 +8989,8 @@ function renderStableContract(config = DEFAULT_ENFORCEMENT) {
8786
8989
  "- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.",
8787
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.",
8788
8991
  "- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.",
8789
- "- 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."
8790
8994
  ].join("\n");
8791
8995
  }
8792
8996
  /**
@@ -11747,11 +11951,16 @@ var RecursiveRuntime = class extends Service {
11747
11951
  title: shard.entry.title,
11748
11952
  score: shard.score
11749
11953
  })), phase);
11954
+ recordMemoryRead(resolved.runDir, phase, {
11955
+ injected: selection.injected,
11956
+ shards: selection.shards.length,
11957
+ reason: selection.reason
11958
+ });
11750
11959
  return {
11751
11960
  runId: resolved.runId,
11752
11961
  phase,
11753
11962
  ...phaseRulesFor(phase),
11754
- memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry)) : "",
11963
+ memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry), "phase") : "",
11755
11964
  memoryReason: selection.reason,
11756
11965
  ...pending === null ? {} : { ask: {
11757
11966
  gate: pending,
@@ -12828,6 +13037,22 @@ function createRecursiveWorktreeTool(recursive) {
12828
13037
  * reminder, so the agent can re-ask for the rules without re-injecting them on
12829
13038
  * every step. Returns { error } when no active phase is found.
12830
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
+ *
12831
13056
  * A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
12832
13057
  * different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
12833
13058
  * answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
@@ -12839,7 +13064,7 @@ function createRecursiveWorktreeTool(recursive) {
12839
13064
  function createRecursivePhaseTool(recursive) {
12840
13065
  return defineTool({
12841
13066
  name: "recursive_phase",
12842
- 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.",
12843
13068
  parameters: { runId: {
12844
13069
  type: "string",
12845
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."