@try-works/dsh-recursive-mode 0.3.0 → 0.4.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.
Files changed (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Explainable memory selection (FU-13 P1/P2) — the reference loader's good idea, with the reasons attached.
3
+ *
4
+ * ⚠ THE REFERENCE, measured: `recursive-training-loader.py` (21.1 KB) *"scores memory docs by relevance to the
5
+ * current task, reads the most relevant docs, scores individual items, and returns formatted context for the
6
+ * agent"* — progressive scoring, which is the right idea. Its weakness is that the ranking is OPAQUE: it
7
+ * returns context, not reasons, so "why did the agent get this?" has no answer and a bad ranking cannot be
8
+ * debugged or tuned.
9
+ *
10
+ * ⚠⚠ AND THE DEFECT THIS MODULE WAS REWRITTEN TO FIX. The first version asserted that one component — the
11
+ * query score — came from production, and then ranked on THREE OTHER SIGNALS OF ITS OWN: it matched the last
12
+ * path SEGMENT where production matches the whole path, capped the path weight where production does not,
13
+ * tested its own retirement pattern instead of `RETIRED_MARKER`, and tie-broke differently. So it described a
14
+ * ranking that does not ship, and the spec passed because it only checked the one shared component. The fix is
15
+ * not "be careful": it is that this module now IMPORTS every constant it ranks with from `memory.ts`, and its
16
+ * spec asserts the only property that cannot be faked — on the same on-disk plane with the same options, this
17
+ * module's order EQUALS `selectMemory`'s shard order.
18
+ */
19
+ import { feedbackBonus, type FeedbackBook } from './memory-feedback.ts'
20
+ import {
21
+ type MemoryEntry,
22
+ MEMORY_PATH_MATCH_WEIGHT,
23
+ MEMORY_PHASE_MATCH_WEIGHT,
24
+ entryAppliesTo,
25
+ RETIRED_MARKER,
26
+ scoreMemoryEntry,
27
+ } from './memory.ts'
28
+
29
+ /** One contribution to an entry's score, named so a reader can argue with it. */
30
+ export interface ScoreComponent {
31
+ name: string
32
+ weight: number
33
+ detail?: string
34
+ }
35
+
36
+ export interface ExplainedEntry {
37
+ kind: string
38
+ title: string
39
+ source: string
40
+ score: number
41
+ /** Sums to `score`. Asserted in the spec, because a breakdown that does not add up is worse than none. */
42
+ components: ScoreComponent[]
43
+ warnings: string[]
44
+ }
45
+
46
+ export interface ExcludedEntry {
47
+ title: string
48
+ source: string
49
+ reason: string
50
+ }
51
+
52
+ export interface MemoryExplanation {
53
+ /** Included entries, best first, each with its components. */
54
+ entries: ExplainedEntry[]
55
+ /** Everything the selector saw and did not inject — never silently dropped. */
56
+ excluded: ExcludedEntry[]
57
+ /** The same rendering shape the review path uses, for the included set. */
58
+ rendered: string
59
+ }
60
+
61
+ export interface ExplainOptions {
62
+ query: string
63
+ /**
64
+ * The run's changed paths. Already the signal FU-4 wired into `selectMemory`, and the one the reference
65
+ * script cannot compute because it runs outside the run.
66
+ */
67
+ files?: readonly string[]
68
+ /** The phase in play. Mirrors production's own option so both rank identically. */
69
+ phase?: string
70
+ /** How many entries may be injected. Mirrors `selectMemory`'s own cap, which is docs-and-items bounded. */
71
+ maxItems?: number
72
+ /** Mirrors `selectMemory`'s doc cap; the two caps compose there, so they must compose here. */
73
+ maxDocs?: number
74
+ /**
75
+ * The counters, PASSED IN. This module takes entries rather than a root, so it has no business reading a
76
+ * sidecar — and an explainer that read its own evidence could disagree with the ranking it describes,
77
+ * which is the defect it was rewritten to remove.
78
+ */
79
+ feedback?: FeedbackBook
80
+ }
81
+
82
+ /** The same compose rule production applies at L246: `slice(0, Math.min(maxDocs, maxItems))`. */
83
+ const DEFAULT_MAX_DOCS = 3
84
+ const DEFAULT_MAX_ITEMS = 10
85
+
86
+ /**
87
+ * Rank entries and explain every decision, using **production's own signals**.
88
+ *
89
+ * The score is `scoreMemoryEntry(entry, query) + <whole-path matches> * MEMORY_PATH_MATCH_WEIGHT`, the retired
90
+ * marker is `RETIRED_MARKER`, and the tie-break is `title.localeCompare` — the four things the first version
91
+ * got wrong by inventing them.
92
+ */
93
+ export function explainMemorySelection(
94
+ entries: readonly MemoryEntry[],
95
+ options: ExplainOptions,
96
+ ): MemoryExplanation {
97
+ const files = options.files ?? []
98
+ const cap = Math.min(options.maxDocs ?? DEFAULT_MAX_DOCS, options.maxItems ?? DEFAULT_MAX_ITEMS)
99
+ const excluded: ExcludedEntry[] = []
100
+ const scored: ExplainedEntry[] = []
101
+
102
+ for (const entry of entries) {
103
+ const haystack = entry.title + '\n' + entry.body
104
+ if (haystack.trim() === '') {
105
+ excluded.push({ title: entry.title, source: entry.source, reason: 'the entry is empty' })
106
+ continue
107
+ }
108
+ if (RETIRED_MARKER.test(haystack)) {
109
+ const status = RETIRED_MARKER.exec(haystack)?.[1] ?? 'retired'
110
+ excluded.push({
111
+ title: entry.title,
112
+ source: entry.source,
113
+ reason: 'status ' + status.toUpperCase() + ' is never injected',
114
+ })
115
+ continue
116
+ }
117
+
118
+ const components: ScoreComponent[] = []
119
+ // ⚠ THE QUERY COMPONENT IS PRODUCTION'S OWN SCORER, not a second implementation of it.
120
+ const queryScore = scoreMemoryEntry(entry, options.query)
121
+ if (queryScore > 0) components.push({ name: 'query-match', weight: queryScore })
122
+
123
+ // ⚠ WHOLE-PATH MATCH, UNCAPPED — production's rule (L227/L229), reproduced rather than improved on. The
124
+ // temptation to match a path's last segment was exactly the drift this module was rewritten to remove.
125
+ const matchedFiles = files.filter((file) => haystack.includes(file))
126
+ if (matchedFiles.length > 0) {
127
+ components.push({
128
+ name: 'path-overlap',
129
+ weight: matchedFiles.length * MEMORY_PATH_MATCH_WEIGHT,
130
+ detail: matchedFiles.slice(0, 3).join(', '),
131
+ })
132
+ }
133
+
134
+ // Production's phase rule, imported rather than re-derived.
135
+ const declared = entryAppliesTo(entry)
136
+ if (options.phase !== undefined && declared.some((p) => options.phase === p || options.phase?.startsWith(p + '-'))) {
137
+ components.push({ name: 'phase-applicable', weight: MEMORY_PHASE_MATCH_WEIGHT, detail: 'declares ' + declared.join(', ') })
138
+ }
139
+
140
+ // Production's counter rule, imported rather than re-derived, and fed the same book.
141
+ const bonus = feedbackBonus(options.feedback ?? {}, entry.source)
142
+ if (bonus !== 0) components.push({ name: 'feedback', weight: bonus, detail: 'applied/contradicted history' })
143
+
144
+ const total = components.reduce((sum, c) => sum + c.weight, 0)
145
+ if (total === 0) {
146
+ excluded.push({
147
+ title: entry.title,
148
+ source: entry.source,
149
+ reason: 'no query match and no overlap with the run\'s changed paths',
150
+ })
151
+ continue
152
+ }
153
+
154
+ scored.push({ kind: entry.kind, title: entry.title, source: entry.source, score: total, components, warnings: [] })
155
+ }
156
+
157
+ // Production's tie-break, exactly: score descending, then title by localeCompare.
158
+ scored.sort((a, b) => (b.score - a.score) || a.title.localeCompare(b.title))
159
+
160
+ const included = scored.slice(0, Math.max(0, cap))
161
+ for (const dropped of scored.slice(Math.max(0, cap))) {
162
+ excluded.push({
163
+ title: dropped.title,
164
+ source: dropped.source,
165
+ reason: 'below the injection budget of ' + cap + ' shard(s)',
166
+ })
167
+ }
168
+
169
+ return { entries: included, excluded, rendered: renderExplained(included) }
170
+ }
171
+
172
+ /** Render the included set, so a caller can print exactly what would be shown. */
173
+ export function renderExplained(entries: readonly ExplainedEntry[]): string {
174
+ if (entries.length === 0) {
175
+ return 'No prior-run memory matched this phase. Do not assume the absence is conclusive:'
176
+ + ' memory is retrieved by relevance, not by recency.'
177
+ }
178
+ const lines: string[] = ['Prior-run memory relevant to this phase (cite by title if you rely on it):']
179
+ for (const entry of entries) {
180
+ lines.push('')
181
+ lines.push('### [' + entry.kind + '] ' + entry.title)
182
+ lines.push('(source: ' + entry.source + ' | score ' + entry.score + ': '
183
+ + entry.components.map((c) => c.name + ' +' + c.weight).join(', ') + ')')
184
+ for (const warning of entry.warnings) lines.push('⚠ ' + warning)
185
+ }
186
+ return lines.join('\n')
187
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * T14 — retrieve prior-run memory into the review bundle, so the recursion COMPOUNDS.
3
+ *
4
+ * WHY. A run that rediscovers what the last run learned is not a recursion, it is a repetition:
5
+ * the workflow already writes memory to `.recursive/memory/` (domains, patterns, episodes, skills),
6
+ * but nothing read it back into a REVIEWER's context, so every review started from nothing and the
7
+ * only party who benefited from a lesson was whoever happened to grep the directory.
8
+ *
9
+ * ⚠ THERE IS NO NATIVE MEMORY SERVICE — measured, not assumed. The item's `memoryStore` names a
10
+ * seam the harness does not provide (there is no memory package), so the store is this plugin's own
11
+ * layer: markdown documents under `.recursive/memory/`. That is why this module PARSES and RANKS
12
+ * rather than delegating; and it is why the ranking has to be explainable, since nothing else is
13
+ * going to decide relevance for it.
14
+ *
15
+ * ⚠ AND RETRIEVAL IS DETERMINISTIC ON PURPOSE. Two runs over the same memory with the same query
16
+ * MUST select the same entries: a reviewer whose context shuffles between runs cannot be compared
17
+ * with itself, and "why did it miss that?" becomes unanswerable. The tie-break is document order,
18
+ * so the result is reproducible from the inputs alone.
19
+ */
20
+
21
+ /** The kinds this plugin's memory layer holds, in the order the scaffold creates them. */
22
+ import { readFileSync, readdirSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ // P3b: the counters, passed IN rather than read here, so a caller decides where the evidence comes from.
25
+ import { type FeedbackBook, feedbackBonus } from './memory-feedback.ts'
26
+
27
+ export const MEMORY_KINDS = ['domains', 'patterns', 'episodes', 'skills'] as const
28
+
29
+ export type MemoryKind = (typeof MEMORY_KINDS)[number]
30
+
31
+ /** One memory note: a titled section of a memory document. */
32
+ export interface MemoryEntry {
33
+ kind: string
34
+ title: string
35
+ body: string
36
+ /** Where it came from, so a citation in a review can be traced. */
37
+ source: string
38
+ }
39
+
40
+ /** How many entries a bundle carries by default — enough to inform, not enough to drown a review. */
41
+ export const DEFAULT_MEMORY_LIMIT = 8
42
+
43
+ /**
44
+ * Split a memory document into entries at its headings.
45
+ *
46
+ * A document with NO headings yields one entry rather than nothing: a memory file written as a
47
+ * single paragraph is still memory, and dropping it because it lacks structure would silently lose
48
+ * exactly the notes a hurried run leaves behind.
49
+ */
50
+ export function parseMemoryEntries(kind: string, text: string, source: string): MemoryEntry[] {
51
+ const lines = text.split(/\r?\n/)
52
+ const entries: MemoryEntry[] = []
53
+ let title: string | null = null
54
+ let body: string[] = []
55
+
56
+ const flush = (): void => {
57
+ const content = body.join('\n').trim()
58
+ if (title === null && content === '') return
59
+ entries.push({ kind, title: title ?? source, body: content, source })
60
+ }
61
+
62
+ for (const line of lines) {
63
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
64
+ if (heading !== null) {
65
+ flush()
66
+ title = heading[2].trim()
67
+ body = []
68
+ continue
69
+ }
70
+ body.push(line)
71
+ }
72
+ flush()
73
+ return entries.filter((entry) => entry.body !== '' || entry.title !== source)
74
+ }
75
+
76
+ /** The distinct lowercase words of a string, for overlap scoring. */
77
+ function terms(text: string): string[] {
78
+ return [...new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length >= 3))]
79
+ }
80
+
81
+ /**
82
+ * Score one entry against a query: a term in the TITLE counts double.
83
+ *
84
+ * Titles are what a note is ABOUT; a term that appears only in the body may be incidental. The
85
+ * weighting is crude and deliberate — an explainable ranking beats a clever one nobody can audit,
86
+ * and the caller can always see every entry it did not get.
87
+ */
88
+ export function scoreMemoryEntry(entry: MemoryEntry, query: string): number {
89
+ const wanted = terms(query)
90
+ if (wanted.length === 0) return 0
91
+ const titleTerms = new Set(terms(entry.title))
92
+ const bodyTerms = new Set(terms(entry.body))
93
+ let score = 0
94
+ for (const term of wanted) {
95
+ if (titleTerms.has(term)) score += 2
96
+ else if (bodyTerms.has(term)) score += 1
97
+ }
98
+ return score
99
+ }
100
+
101
+ /**
102
+ * Rank entries against a query, keeping only those that MATCH.
103
+ *
104
+ * A zero score means no overlap, and an unmatched entry is not "less relevant" — it is unrelated,
105
+ * and padding a reviewer's context with it would cost attention for nothing. Ties break by the
106
+ * original order (a stable sort), so the same inputs always give the same answer.
107
+ */
108
+ export function retrieveMemory(entries: readonly MemoryEntry[], query: string, limit: number = DEFAULT_MEMORY_LIMIT): MemoryEntry[] {
109
+ const scored = entries.map((entry, index) => ({ entry, index, score: scoreMemoryEntry(entry, query) }))
110
+ return scored
111
+ .filter((row) => row.score > 0)
112
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
113
+ .slice(0, Math.max(0, limit))
114
+ .map((row) => row.entry)
115
+ }
116
+
117
+ /**
118
+ * Read the plugin's memory layer. Best-effort and never throws: an unreadable memory directory is
119
+ * a missing advantage, not a failed review.
120
+ *
121
+ * `readFile` is injected so the caller decides how files are read (and a test can drive it without
122
+ * a filesystem), which is also how the layout stays in ONE place — `.recursive/memory/<kind>/*.md`.
123
+ */
124
+ export function readMemoryEntries(
125
+ readFile: (path: string) => string | null,
126
+ listFiles: (kind: string) => readonly string[],
127
+ kinds: readonly string[] = MEMORY_KINDS,
128
+ ): MemoryEntry[] {
129
+ const entries: MemoryEntry[] = []
130
+ for (const kind of kinds) {
131
+ for (const path of listFiles(kind)) {
132
+ const text = readFile(path)
133
+ if (text === null || text.trim() === '') continue
134
+ entries.push(...parseMemoryEntries(kind, text, path))
135
+ }
136
+ }
137
+ return entries
138
+ }
139
+
140
+ /** Render the retrieved memory for a review bundle: sections a reviewer can cite by title. */
141
+ export function renderMemorySection(entries: readonly MemoryEntry[]): string { if (entries.length === 0) {
142
+ return 'No prior-run memory matched this review. Do not assume the absence is conclusive:'
143
+ + ' memory is retrieved by relevance to the artifact and phase, not by recency.'
144
+ }
145
+ const lines: string[] = ['Prior-run memory relevant to this review (cite by title if you rely on it):']
146
+ for (const entry of entries) {
147
+ lines.push('')
148
+ lines.push('### [' + entry.kind + '] ' + entry.title)
149
+ if (entry.body !== '') lines.push(entry.body)
150
+ lines.push('(source: ' + entry.source + ')')
151
+ }
152
+ return lines.join('\n')
153
+ }
154
+
155
+ /**
156
+ * T29 — INJECT MEMORY AT RUN START: the loader.
157
+ *
158
+ * WHY. The plugin built and linted the memory plane and **never read it**, so every run started from
159
+ * zero and the whole temporal axis was dead. This is the read half of that axis; {@link selectMemory}
160
+ * is the progressive-disclosure rule that keeps it from becoming a firehose.
161
+ *
162
+ * ⚠ RETIRED ENTRIES ARE NEVER INJECTED. `MemoryEntry` has no status FIELD, so the marker is read from
163
+ * the entry's own text (`Status: STALE` / `Status: DEPRECATED`) — and an entry that says it is retired
164
+ * is the one thing a later run must not be taught. Superseding writes a new entry; this is what makes
165
+ * the old one stop being read.
166
+ */
167
+ // ⚠ EXPORTED SO THERE IS ONE COPY: an explainer that tested its own retirement pattern would describe a
168
+ // ranking that does not ship - which is exactly the defect memory-select.ts had before this change.
169
+ export const RETIRED_MARKER = /\bStatus:\s*`?(STALE|DEPRECATED)`?/i
170
+
171
+ /**
172
+ * What one matched changed path is worth. Exported for the same reason as the marker above: the explainer
173
+ * must add the number production adds, not a number that looks reasonable.
174
+ */
175
+ export const MEMORY_PATH_MATCH_WEIGHT = 3
176
+
177
+ /**
178
+ * What an entry that declares it applies to THIS phase is worth. Exported for the same reason as the two
179
+ * constants above: the explainer must add the number production adds.
180
+ */
181
+ export const MEMORY_PHASE_MATCH_WEIGHT = 2
182
+
183
+ /**
184
+ * The phase-applicability convention: an entry declares Applies to: 03, 03.5 in its body.
185
+ *
186
+ * Declaring NOTHING means general guidance, which is why the bonus is only ever added and never subtracted:
187
+ * an entry that names other phases simply earns no bonus, and the ranking order decides.
188
+ */
189
+ export function entryAppliesTo(entry: MemoryEntry): string[] {
190
+ const match = /^[ \t>*_-]*applies to[ \t]*:[ \t]*(.+)$/im.exec(entry.body)
191
+ if (!match || match[1] === undefined) return []
192
+ return match[1].split(',').map((part) => part.replace(/[^0-9.]/g, '')).filter((part) => part !== '')
193
+ }
194
+
195
+ /** The registry the loader starts from: the router, not the plane. */
196
+ export const MEMORY_INDEX_FILE = 'memory/MEMORY.md'
197
+
198
+ /** Progressive disclosure defaults, matching the parent (`--max-docs` 3, `--max-items` 10). */
199
+ export const MAX_MEMORY_DOCS = 3
200
+ export const MAX_MEMORY_ITEMS = 10
201
+
202
+ /** Read the whole plane, minus nothing: filtering is the SELECTOR's job, not the reader's. */
203
+ export function loadMemoryIndex(
204
+ root: string,
205
+ readFile: (path: string) => string | null = defaultMemoryRead,
206
+ listFiles: (kind: string) => readonly string[] = (kind) => defaultMemoryList(root, kind),
207
+ ): MemoryEntry[] {
208
+ return readMemoryEntries(readFile, listFiles)
209
+ }
210
+
211
+ /** A shard, as the loader returns it: the entry plus why it was selected. */
212
+ export interface MemoryShard {
213
+ entry: MemoryEntry
214
+ score: number
215
+ /** The query terms or paths that matched. Empty for an entry kept only because it is pinned. */
216
+ matched: string[]
217
+ }
218
+
219
+ export interface MemorySelection {
220
+ /** The shards to inject, best first. EMPTY when nothing was relevant. */
221
+ shards: MemoryShard[]
222
+ /** False when nothing is injected — the caller must then continue WITHOUT fabricating memory. */
223
+ injected: boolean
224
+ reason: string
225
+ }
226
+
227
+ /**
228
+ * Select what to inject.
229
+ *
230
+ * ⚠ PATHS ARE THE STRONGER SIGNAL, per the parent: a shard whose text mentions a path the run has
231
+ * actually changed outranks one that merely shares wording with the query. Both matter, and the path
232
+ * evidence is weighted — not substituted, because a run's requirements text is what says what the run
233
+ * is FOR.
234
+ *
235
+ * ⚠ NOTHING RELEVANT MEANS NOTHING INJECTED, verbatim from the parent: *"If the loader finds nothing
236
+ * relevant, continue normally rather than fabricating memory."* A zero-score entry is NOT a weak
237
+ * match, it is no match, and `injected: false` says so.
238
+ */
239
+ export function selectMemory(
240
+ root: string,
241
+ options: { query: string; files?: readonly string[]; phase?: string; feedback?: FeedbackBook; maxDocs?: number; maxItems?: number },
242
+ ): MemorySelection {
243
+ const maxDocs = options.maxDocs ?? MAX_MEMORY_DOCS
244
+ const maxItems = options.maxItems ?? MAX_MEMORY_ITEMS
245
+ const entries = loadMemoryIndex(root)
246
+ if (entries.length === 0) {
247
+ return { shards: [], injected: false, reason: 'the memory plane is empty, so nothing is injected' }
248
+ }
249
+
250
+ const files = options.files ?? []
251
+ const candidates: MemoryShard[] = []
252
+ for (const entry of entries) {
253
+ const haystack = entry.title + '\n' + entry.body
254
+ if (RETIRED_MARKER.test(haystack)) continue
255
+ const matchedFiles = files.filter((file) => haystack.includes(file))
256
+ const base = scoreMemoryEntry(entry, options.query)
257
+ // ⚠ FU-14 — P2's SECOND HALF, which the first attempt silently missed. The constant, the parser and the
258
+ // `phase` option all landed; THIS LINE did not get the bonus, so production ignored the phase while the
259
+ // explainer honoured it, and the order-equality test missed it because its phase case had ONE relevant
260
+ // entry — whose position cannot change however it scores. A test that cannot fail is not a safety net.
261
+ const phaseApplies = options.phase !== undefined
262
+ && entryAppliesTo(entry).some((p) => options.phase === p || options.phase?.startsWith(p + '-'))
263
+ const score = base + matchedFiles.length * MEMORY_PATH_MATCH_WEIGHT
264
+ + (phaseApplies ? MEMORY_PHASE_MATCH_WEIGHT : 0)
265
+ // P3b: what the counters have learned about this entry, clamped to one step by `feedbackBonus`. Passed
266
+ // in as an option, so this module never decides where evidence lives.
267
+ + feedbackBonus(options.feedback ?? {}, entry.source)
268
+ if (score === 0) continue
269
+ const matched = matchedFiles.length > 0
270
+ ? matchedFiles
271
+ : options.query.split(/\s+/).filter((term) => term.length > 2 && haystack.toLowerCase().includes(term.toLowerCase()))
272
+ candidates.push({ entry, score, matched: [...new Set(matched)].slice(0, 5) })
273
+ }
274
+
275
+ if (candidates.length === 0) {
276
+ return {
277
+ shards: [],
278
+ injected: false,
279
+ reason: 'no memory entry matches this run and none is retired-but-relevant, so nothing is injected rather than fabricating memory',
280
+ }
281
+ }
282
+
283
+ candidates.sort((a, b) => (b.score - a.score) || a.entry.title.localeCompare(b.entry.title))
284
+ const shards = candidates.slice(0, Math.min(maxDocs, maxItems))
285
+ return {
286
+ shards,
287
+ injected: true,
288
+ reason: 'injected ' + shards.length + ' of ' + candidates.length + ' matching shard(s), capped at maxDocs ' + maxDocs,
289
+ }
290
+ }
291
+
292
+ function defaultMemoryRead(path: string): string | null {
293
+ try {
294
+ return readFileSync(path, 'utf8')
295
+ } catch {
296
+ return null
297
+ }
298
+ }
299
+
300
+ function defaultMemoryList(root: string, kind: string): readonly string[] {
301
+ const dir = join(root, 'memory', kind)
302
+ try {
303
+ return readdirSync(dir)
304
+ .filter((name) => name.endsWith('.md'))
305
+ .map((name) => join(dir, name))
306
+ } catch {
307
+ return []
308
+ }
309
+ }