@try-works/dsh-recursive-mode 0.4.9 → 0.6.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/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
package/lib/config.d.ts CHANGED
@@ -48,6 +48,13 @@ export interface RecursiveModeConfig {
48
48
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
49
49
  shellOnly: z<boolean, boolean, "defined">;
50
50
  repoRoot: z<string, string, "plain">;
51
+ /**
52
+ * ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
53
+ * that is deliberate: the schema default and the runtime default are the SAME value, and
54
+ * two literals here would be two defaults. A caller that omits the section gets
55
+ * `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
56
+ * the resolver's fill. Both must be the enforcing posture — see the const for why.
57
+ */
51
58
  enforcement: z<Schemastery.ObjectS<NoInfer<{
52
59
  preStep: z<"strict" | "advisory", "strict" | "advisory", "defined">;
53
60
  toolGuards: z<"strict" | "advisory", "strict" | "advisory", "defined">;
@@ -125,6 +132,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
125
132
  }>>, Schemastery.ObjectT<NoInfer<{
126
133
  shellOnly: z<boolean, boolean, "defined">;
127
134
  repoRoot: z<string, string, "plain">;
135
+ /**
136
+ * ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
137
+ * that is deliberate: the schema default and the runtime default are the SAME value, and
138
+ * two literals here would be two defaults. A caller that omits the section gets
139
+ * `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
140
+ * the resolver's fill. Both must be the enforcing posture — see the const for why.
141
+ */
128
142
  enforcement: z<Schemastery.ObjectS<NoInfer<{
129
143
  preStep: z<"strict" | "advisory", "strict" | "advisory", "defined">;
130
144
  toolGuards: z<"strict" | "advisory", "strict" | "advisory", "defined">;
@@ -1,4 +1,5 @@
1
1
  import { builtInToolPolicyDefault, type ToolPolicy } from './policy-globs.ts';
2
+ import { type GateBlockAsk } from './recursive_ask.tool.ts';
2
3
  /**
3
4
  * The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
4
5
  * beside the evaluator and the loader that use it as the ABSENT-file fallback,
@@ -40,6 +41,28 @@ export interface EnforcementConfig {
40
41
  /** T28: the caps. Always present, so a caller never has to guess a default. */
41
42
  budgets: BudgetConfig;
42
43
  }
44
+ /**
45
+ * THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
46
+ *
47
+ * The owner's rule is *only one phase may be active at a time, and the phases should be
48
+ * sequential and the active phase must be locked before proceeding to next phase*. In
49
+ * `advisory` that rule is only WARNED about, and a live run showed what that costs: the
50
+ * run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
51
+ * nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
52
+ * a default because it also refused the run's OWN artifacts — a false positive. That was
53
+ * fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
54
+ * active artifact stays writable while a later one is refused. Strict therefore refuses
55
+ * exactly the ordering violations it is meant to refuse, so the default is the enforcing
56
+ * posture rather than a warning nobody has to act on.
57
+ *
58
+ * ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
59
+ * project's recurring failure: the same value exists in the Config schema, in
60
+ * `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
61
+ * the helpers below, and moving only some of them leaves a caller that "still gets
62
+ * advisory". Every one of those sites now reads THIS const, so a revert is a one-line
63
+ * change and nothing can drift from it.
64
+ */
65
+ export declare const DEFAULT_ENFORCEMENT_MODE: EnforcementMode;
43
66
  /**
44
67
  * Validate the enforcement config shape (unknown keys fail at plugin load).
45
68
  *
@@ -49,6 +72,12 @@ export interface EnforcementConfig {
49
72
  * A config error is loud at load, not mysterious later.
50
73
  */
51
74
  export declare function resolveEnforcementConfig(config: unknown): EnforcementConfig;
75
+ /**
76
+ * The runtime default: what a caller gets when it supplies no `enforcement` section at all
77
+ * (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
78
+ * `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
79
+ * see that const for WHY the default is strict.
80
+ */
52
81
  export declare const DEFAULT_ENFORCEMENT: EnforcementConfig;
53
82
  /**
54
83
  * T15: the rule that produced a guard decision (a machine-readable reason for
@@ -77,22 +106,39 @@ export interface GuardTransition {
77
106
  * optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
78
107
  * (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
79
108
  * grow extra keys. `evaluateToolGuard` itself always sets `rule`.
109
+ *
110
+ * ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
111
+ * PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
112
+ * and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
113
+ * refusals cannot offer different options. It is absent on every decision that is not a lock-order
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.
80
122
  */
81
123
  export type ToolGuardDecision = {
82
124
  kind: 'allow';
83
125
  warn?: string;
84
126
  rule?: GuardRule;
85
127
  transition?: GuardTransition;
128
+ runId?: string;
86
129
  } | {
87
130
  kind: 'deny';
88
131
  reason: string;
89
132
  rule?: GuardRule;
90
133
  transition?: GuardTransition;
134
+ ask?: GateBlockAsk;
135
+ runId?: string;
91
136
  } | {
92
137
  kind: 'ask';
93
138
  reason?: string;
94
139
  rule?: GuardRule;
95
140
  transition?: GuardTransition;
141
+ runId?: string;
96
142
  };
97
143
  export interface ToolExecLike {
98
144
  name: string;
@@ -122,12 +168,84 @@ export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: s
122
168
  * needs, because a cached phase would apply yesterday's baseline to today's lock.
123
169
  */
124
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;
219
+ /**
220
+ * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
221
+ * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
222
+ * literal: a bare call is "the caller had no mode to hand", and the answer to that must
223
+ * be the same posture the config would have produced. Two literals are two defaults, and
224
+ * a helper left on the old `advisory` literal while the config moved to `strict` is
225
+ * exactly the twin-default hole this change closes — a caller that forgot the argument
226
+ * would silently get the permissive branch, which no config could then undo. Every
227
+ * production call site passes the mode explicitly (`index.ts` `runToolGuard`,
228
+ * `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
229
+ * bare caller must not be the one place enforcement quietly turns itself off.
230
+ */
125
231
  export declare function evaluateToolGuard(exec: ToolExecLike, worktreeRoot: string, activeRunId: string, mode?: EnforcementMode): ToolGuardDecision;
126
232
  /**
127
233
  * T6 (approval ask→policy bridge): an `ask` decision must never be a silent
128
234
  * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
129
235
  * but flags a `warn` so the caller never lets it through unlogged. Non-ask
130
236
  * decisions pass through unchanged.
237
+ *
238
+ * ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
239
+ * neutral branch here. The domain is two postures, one of which ALLOWS the call, so
240
+ * "unspecified" has to be resolved rather than left open, and this codebase's rule for an
241
+ * undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
242
+ * permission*). It therefore FOLLOWS the config default by REFERENCE
243
+ * (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
244
+ * `advisory` literal here would be a second, hidden copy of the old default inside the
245
+ * very module this change moves, and a future caller that omitted the argument would
246
+ * re-open the permissive path with no config able to close it. The production call site
247
+ * (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
248
+ * behaviour — it removes the last place where "we were not told" meant "allow".
131
249
  */
132
250
  export declare function coerceAskToDecision(decision: ToolGuardDecision, mode?: EnforcementMode): ToolGuardDecision;
133
251
  /**
@@ -1,8 +1,14 @@
1
1
  import type { GuardRule } from './enforcement.ts';
2
+ import type { GateBlockAsk } from './recursive_ask.tool.ts';
2
3
  /**
3
4
  * One logged guard decision (the JSONL record shape the board/tests read).
4
5
  * `rule` is always set (the guard's own machine-readable reason for the
5
6
  * verdict); `transition` is present whenever the transition gate was consulted.
7
+ *
8
+ * FU-7: a REFUSAL that a person has to resolve also carries `ask` — the gate-block decision, in the
9
+ * same shape `recursive_lock` attaches to its own refusal. It is recorded because the log is where
10
+ * "why was this lock refused?" is answered, and the options are the other half of that answer; a
11
+ * caller (or a board) reading the trace can act on the refusal without parsing the sentence.
6
12
  */
7
13
  export interface GuardDecisionRecord {
8
14
  at: string;
@@ -15,6 +21,7 @@ export interface GuardDecisionRecord {
15
21
  passed: boolean;
16
22
  failures: string[];
17
23
  };
24
+ ask?: GateBlockAsk;
18
25
  }
19
26
  /** One logged observed-write tamper (a LOCKED artifact whose hash no longer matches). */
20
27
  export interface ObservedTamperRecord {