@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.
- package/lib/enforcement.d.ts +6 -1
- package/lib/index.js +398 -173
- package/lib/memory-feedback.d.ts +83 -0
- package/lib/memory.d.ts +15 -2
- package/lib/recursive_phase.tool.d.ts +16 -0
- package/package.json +1 -1
- package/scripts/check-workflow-map-escapes.mjs +103 -0
- package/scripts/check-workflow-map.mjs +423 -0
- package/scripts/gen-workflow-map.mjs +2812 -0
- package/src/enforcement.ts +6 -1
- package/src/memory-feedback.ts +148 -3
- package/src/memory.ts +30 -4
- package/src/policy-globs.ts +111 -0
- package/src/policy.ts +17 -0
- package/src/recursive_phase.tool.ts +24 -1
- package/src/runtime.ts +18 -2
package/lib/enforcement.d.ts
CHANGED
|
@@ -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
|
|
6554
|
-
function renderMemorySection(entries) {
|
|
6555
|
-
|
|
6556
|
-
|
|
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: "
|
|
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."
|