@try-works/dsh-recursive-mode 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -289,10 +289,9 @@ Two conventions matter more than they look:
289
289
  repoRoot)` resolves the workspace the *session* is in. A plugin that used `process.cwd()` would silently lint the
290
290
  wrong repository when the host checkout and the workspace differ.
291
291
 
292
- **The memory plane is read at `<controlPlaneRoot>/memory/<kind>/`.** This was measured, not assumed: a probe
293
- showed `<dir>/memory/domains` is selected, `<dir>/.recursive/memory/...` is not, and using `.recursive` as the
294
- root is selected again. A behaviour test that seeds the wrong path fails *exactly like* a broken implementation,
295
- which is why the distinction is written down here.
292
+ **The memory plane is read from the plane the scaffold creates, not from beside it.** `loadMemoryIndex` resolves each kind under `<controlPlaneRoot>/.recursive/memory/<kind>/` — the directory `bootstrap.ts` scaffolds, `ts-lint.ts` lints, and the review bundle already read — and falls back to `<controlPlaneRoot>/memory/<kind>/` only when that kind has no readable shard there. That is the same **prefer-then-fall-back, never merge** rule `readFeedback` follows for its own moved sidecar: the earlier location is consulted only when the current one yields nothing, because two snapshots of one shard added together would count a shard twice.
293
+
294
+ Before that fallback existed the loader joined `memory/` straight onto the root it was handed, so it read `<dir>/memory/domains` — **a directory no real workspace has** — while the plane sat at `<dir>/.recursive/memory/domains`. Measured live: a workspace with the plane scaffolded, `memory/training/` included, and no `<dir>/memory/` at all, where `selectMemory` reported *"the memory plane is empty"* on every phase of every run. Passing `.recursive` itself as the root still resolves, through the second entry.
296
295
 
297
296
  ---
298
297
 
@@ -562,11 +561,12 @@ inputs are visible.
562
561
 
563
562
  ```mermaid
564
563
  flowchart LR
565
- subgraph sources["memory/&lt;kind&gt;/*.md"]
564
+ subgraph sources[".recursive/memory/&lt;kind&gt;/*.md"]
566
565
  D1["domains/"]
567
566
  D2["patterns/"]
568
- D3["incidents/"]
569
- D4["episodes/"]
567
+ D3["episodes/"]
568
+ D4["training/"]
569
+ D5["skills/"]
570
570
  end
571
571
 
572
572
  Q["query:<br/>the run's own<br/>00-requirements.md<br/>(first 4000 chars)"] --> SEL
@@ -578,8 +578,9 @@ flowchart LR
578
578
  D2 --> IDX
579
579
  D3 --> IDX
580
580
  D4 --> IDX
581
+ D5 --> IDX
581
582
 
582
- SEL --> OUT["top N shards →<br/>the review bundle"]
583
+ SEL --> OUT["top N shards →<br/>the phase payload<br/>and the review bundle"]
583
584
  SEL --> REC["recordInjection()<br/>memory-injections.json"]
584
585
  REC --> SET["settleInjections() at closeout<br/>over the LOCKED phases only"]
585
586
  SET --> FB
@@ -588,6 +589,12 @@ flowchart LR
588
589
  class Q,F,P,FB input
589
590
  ```
590
591
 
592
+ **`MEMORY_KINDS` is the list of kinds that are auto-loaded, and it is exactly those five.** `incidents/` and
593
+ `archive/` are stored and documented by the router but are *not* auto-loaded — `archive/` is historical by the
594
+ router's own definition, and adding `incidents/` is a ranking decision rather than a typo fix. `training/` **is**
595
+ in the list because it is the kind the phase-8 trigger writes: with it absent, the shards training produced were
596
+ written and registered in `MEMORY.md` while the loader that was supposed to score them could not see the kind.
597
+
591
598
  **Scoring is additive and inspectable.** A shard scores for matching the query, for overlapping the changed
592
599
  paths (`MEMORY_PATH_MATCH_WEIGHT = 3`), for applying to the current phase (`MEMORY_PHASE_MATCH_WEIGHT = 2`), and
593
600
  for its feedback history (`feedbackBonus`, clamped to ±1). `explainMemorySelection` returns the **components** per
@@ -689,6 +696,12 @@ Which closes the loop the [memory plane](#9-how-it-works-the-memory-plane) opene
689
696
  closeout, and `feedbackBonus` nudges the next selection. The plugin's prompts and its context are meant to
690
697
  improve from its own recorded outcomes rather than from someone's recollection of them.
691
698
 
699
+ **And the loop is only closed when the reader can see every kind the writer produces.** The trigger writes one
700
+ shard per subsystem into `memory/domains/` *and* one per task type into `memory/training/<task-type>.md`, and the
701
+ second of those is the reason `training` is in `MEMORY_KINDS` (§9): a writer whose output the reader cannot reach
702
+ is the loop being described rather than closed. `tests/training-trigger.spec.ts` asserts the round trip end to
703
+ end — trigger writes, loader finds the shard it wrote — because either half can look correct alone.
704
+
692
705
  **Design and status:** [TRAINING.md](TRAINING.md) is the design — including the phases P1–P5, each with a live
693
706
  acceptance, and the measured comparison with the reference implementation it deliberately does **not** copy.
694
707
  Its non-goals are as load-bearing as its goals: this is not a fine-tuning pipeline, and it does not ship a model
@@ -112,23 +112,33 @@ export interface GuardTransition {
112
112
  * and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
113
113
  * refusals cannot offer different options. It is absent on every decision that is not a lock-order
114
114
  * refusal decided from real blockers, which is why every reader must treat it as optional.
115
+ *
116
+ * ⚠ ISSUE 2 (b): `runId` IS THE RUN THE GUARD RESOLVED AND READ — optional and additive for the same
117
+ * reason. `evaluateToolGuard` sets it on EVERY decision, allow included, and `index.ts` logs it instead of
118
+ * the filesystem's active run: a record whose `runId` came from one resolution while the rule read another
119
+ * is exactly the two-answers-in-one-payload defect this pairs with. It stays OPTIONAL so a hand-built
120
+ * decision (and `coerceAskToDecision`'s key-frozen input/output) is unaffected; a reader falls back to the
121
+ * active run when it is absent, which is the pre-existing behaviour.
115
122
  */
116
123
  export type ToolGuardDecision = {
117
124
  kind: 'allow';
118
125
  warn?: string;
119
126
  rule?: GuardRule;
120
127
  transition?: GuardTransition;
128
+ runId?: string;
121
129
  } | {
122
130
  kind: 'deny';
123
131
  reason: string;
124
132
  rule?: GuardRule;
125
133
  transition?: GuardTransition;
126
134
  ask?: GateBlockAsk;
135
+ runId?: string;
127
136
  } | {
128
137
  kind: 'ask';
129
138
  reason?: string;
130
139
  rule?: GuardRule;
131
140
  transition?: GuardTransition;
141
+ runId?: string;
132
142
  };
133
143
  export interface ToolExecLike {
134
144
  name: string;
@@ -158,6 +168,54 @@ export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: s
158
168
  * needs, because a cached phase would apply yesterday's baseline to today's lock.
159
169
  */
160
170
  export declare function currentPhaseArtifact(worktreeRoot: string, runId: string): string;
171
+ /**
172
+ * ISSUE 2 (a) — THE RUN A GUARD CALL IS ABOUT, and the one whose tree it may read.
173
+ *
174
+ * THE DEFECT THIS ANSWERS, measured before the fix: `recursive_lock {runId: 'run-b', artifact:
175
+ * '01-as-is.md'}` was REFUSED with `monotonic lock-order: … 00-requirements.md (DRAFT)` — run-A's blocker —
176
+ * while `run-b` had `00-requirements.md` LOCKED and `01-as-is.md` DRAFT, so locking it in run-b was LEGAL.
177
+ * The guard resolved the run from the FILESYSTEM (`resolveRunDir`, i.e. the active/newest run) while the
178
+ * tool resolves it from `args.runId`, so the guard judged a DIFFERENT RUN than the call was about. Under
179
+ * `advisory` the deny was coerced to an allow-with-warning and the tool refused on its own terms, which is
180
+ * why the strict default is what made it bite.
181
+ *
182
+ * SO THE RULE IS: for a LOCK call that NAMES a run, the guard judges THAT RUN. It is the same choice the
183
+ * tool makes, so the two layers cannot disagree about which tree the ordering rule is a property of. A
184
+ * caller that names nothing (every real `write`, and a lock that relies on the active run) is unaffected:
185
+ * the active run still governs, which is what the write-side rules rely on.
186
+ *
187
+ * ⚠ THIS IS SCOPED TO THE LOCK TOOLS DELIBERATELY, and the scope is per rule, not per convenience:
188
+ *
189
+ * - `lock-order` (`recursive_lock*`) — the caller's run WINS. The tool acts on `args.runId`, and the
190
+ * rule is about THAT run's prerequisites, so the guard must not answer for another run. This is the
191
+ * measured defect.
192
+ * - `locked-write` (the write-tool family) — NOT APPLICABLE, by construction: the rule resolves no run
193
+ * at all. It reads the target file's own `Status:` through the path the caller named, so there is no
194
+ * run to prefer and nothing could disagree.
195
+ * - `phase-order` (the write-tool family) — the ACTIVE run KEEPS WINNING, and this function does not
196
+ * touch it. Two reasons, both deliberate: (1) a `write` call carries no run id — no write tool declares
197
+ * one — so consulting `args.runId` here would hand a caller a way to ESCAPE the active run's ordering
198
+ * by naming some other run in an argument the tool ignores; and (2) the rule's declared scope is the
199
+ * run being worked in (it abstains for another run's tree, documented in `phaseOrderRule`), and moving
200
+ * that scope would be a new refusal, not a consistency fix.
201
+ *
202
+ * ⚠ A CALLER-SUPPLIED ID IS A NAME, NEVER A PATH, and it is validated before it can point the guard at
203
+ * anything: the id is trimmed the way `recursive_lock` trims it, then put through `runIdProblem` — the
204
+ * SAME gate the run-id-shaped tools use, which refuses separators, drive specifiers, `..`, a colon, a
205
+ * leading/trailing dot and an over-long name — and finally the resolved directory must sit UNDER this
206
+ * worktree's `<root>/.recursive/run`, the containment rule `runtime.ts` applies to a run directory.
207
+ *
208
+ * An id that fails any of those is NOT USED: the guard falls back to the active run, exactly as it behaved
209
+ * before this change. Falling back (rather than denying) is deliberate: an unusable id is a caller mistake
210
+ * the tool itself refuses (`BAD_RUN_ID` / `Artifact not found`), and inventing a new guard refusal for it
211
+ * would be a second, competing answer to a question `runIdProblem` already owns.
212
+ *
213
+ * A usable id does NOT have to name an EXISTING run: a run with no tree has no unlocked prerequisites, so
214
+ * the ordering rule abstains and the LOCK TOOL still refuses the lock (it checks the artifact exists before
215
+ * anything else). Requiring existence would instead re-introduce the defect in its ugliest form — a refusal
216
+ * built from ANOTHER run's blockers.
217
+ */
218
+ export declare function resolveGuardRunId(name: string, args: Record<string, unknown>, worktreeRoot: string, activeRunId: string): string;
161
219
  /**
162
220
  * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
163
221
  * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the