@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.
@@ -3,11 +3,12 @@
3
3
  * (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
4
4
  *
5
5
  * Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
6
- * layers. Configurable strict|advisory per gate (default advisory).
6
+ * layers. Configurable strict|advisory per gate (default strict).
7
7
  */
8
8
  import { existsSync, readdirSync } from 'node:fs'
9
9
  import { join, isAbsolute, resolve, sep } from 'node:path'
10
10
  import { getLockStatus } from './lock.ts'
11
+ import { runIdProblem } from './run-id.ts'
11
12
  import { validateTransition, type GateCheckResult } from './lifecycle.ts'
12
13
  import {
13
14
  evaluateToolPolicy, loadToolPolicyFile,
@@ -15,6 +16,7 @@ import {
15
16
  type ToolPolicy, type ToolPolicyContext, type Decision as PolicyDecision,
16
17
  } from './policy-globs.ts'
17
18
  import { withPhaseBaseline, phaseNumberForArtifact, resolveFrom } from './phase-rules.ts'
19
+ import { buildGateBlockAsk, type GateBlockAsk } from './recursive_ask.tool.ts'
18
20
 
19
21
  /**
20
22
  * The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
@@ -75,6 +77,29 @@ const BUDGET_KEYS: ReadonlyArray<keyof BudgetConfig> = [
75
77
  'maxAuditRounds', 'maxRepairAttempts', 'maxDelegationDepth', 'maxChildrenPerPhase', 'maxResultBytes',
76
78
  ]
77
79
 
80
+ /**
81
+ * THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
82
+ *
83
+ * The owner's rule is *only one phase may be active at a time, and the phases should be
84
+ * sequential and the active phase must be locked before proceeding to next phase*. In
85
+ * `advisory` that rule is only WARNED about, and a live run showed what that costs: the
86
+ * run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
87
+ * nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
88
+ * a default because it also refused the run's OWN artifacts — a false positive. That was
89
+ * fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
90
+ * active artifact stays writable while a later one is refused. Strict therefore refuses
91
+ * exactly the ordering violations it is meant to refuse, so the default is the enforcing
92
+ * posture rather than a warning nobody has to act on.
93
+ *
94
+ * ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
95
+ * project's recurring failure: the same value exists in the Config schema, in
96
+ * `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
97
+ * the helpers below, and moving only some of them leaves a caller that "still gets
98
+ * advisory". Every one of those sites now reads THIS const, so a revert is a one-line
99
+ * change and nothing can drift from it.
100
+ */
101
+ export const DEFAULT_ENFORCEMENT_MODE: EnforcementMode = 'strict'
102
+
78
103
  /**
79
104
  * Validate the enforcement config shape (unknown keys fail at plugin load).
80
105
  *
@@ -89,7 +114,17 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
89
114
  if (unknown.length > 0) {
90
115
  throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper, budgets }')
91
116
  }
92
- const mode = (value: unknown): EnforcementMode => (value === 'strict' ? 'strict' : 'advisory')
117
+ // ⚠ AN ABSENT MODE RESOLVES TO THE DEFAULT MODE, not to the permissive branch. This is
118
+ // the twin-default trap in its most consequential form: a caller that supplies a PARTIAL
119
+ // section — `enforcement: { toolGuards: 'advisory' }` from a settings patch, or just the
120
+ // budgets — leaves the other gates unstated, and filling those with `advisory` would
121
+ // hand back a config that is looser than the plugin's own default with nothing saying so.
122
+ // An UNRECOGNIZED value resolves the same way and thus fails CLOSED. The Config schema in
123
+ // src/config.ts still rejects a typo loudly at the settings boundary; this resolver is
124
+ // the lenient one, and a lenient resolver must bend towards the safe posture: a typo that
125
+ // blocks is a visible stop, a typo that permits is the hour-long out-of-order run again.
126
+ const mode = (value: unknown): EnforcementMode =>
127
+ value === 'strict' ? 'strict' : value === 'advisory' ? 'advisory' : DEFAULT_ENFORCEMENT_MODE
93
128
 
94
129
  const rawBudgets = (raw.budgets ?? {}) as Record<string, unknown>
95
130
  if (typeof raw.budgets !== 'undefined' && (raw.budgets === null || typeof raw.budgets !== 'object')) {
@@ -117,10 +152,16 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
117
152
  }
118
153
  }
119
154
 
155
+ /**
156
+ * The runtime default: what a caller gets when it supplies no `enforcement` section at all
157
+ * (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
158
+ * `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
159
+ * see that const for WHY the default is strict.
160
+ */
120
161
  export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
121
- preStep: 'advisory',
122
- toolGuards: 'advisory',
123
- tamper: 'advisory',
162
+ preStep: DEFAULT_ENFORCEMENT_MODE,
163
+ toolGuards: DEFAULT_ENFORCEMENT_MODE,
164
+ tamper: DEFAULT_ENFORCEMENT_MODE,
124
165
  budgets: DEFAULT_BUDGETS,
125
166
  }
126
167
 
@@ -153,11 +194,24 @@ export interface GuardTransition {
153
194
  * optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
154
195
  * (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
155
196
  * grow extra keys. `evaluateToolGuard` itself always sets `rule`.
197
+ *
198
+ * ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
199
+ * PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
200
+ * and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
201
+ * refusals cannot offer different options. It is absent on every decision that is not a lock-order
202
+ * refusal decided from real blockers, which is why every reader must treat it as optional.
203
+ *
204
+ * ⚠ ISSUE 2 (b): `runId` IS THE RUN THE GUARD RESOLVED AND READ — optional and additive for the same
205
+ * reason. `evaluateToolGuard` sets it on EVERY decision, allow included, and `index.ts` logs it instead of
206
+ * the filesystem's active run: a record whose `runId` came from one resolution while the rule read another
207
+ * is exactly the two-answers-in-one-payload defect this pairs with. It stays OPTIONAL so a hand-built
208
+ * decision (and `coerceAskToDecision`'s key-frozen input/output) is unaffected; a reader falls back to the
209
+ * active run when it is absent, which is the pre-existing behaviour.
156
210
  */
157
211
  export type ToolGuardDecision =
158
- | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
159
- | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition }
160
- | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
212
+ | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
213
+ | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk; runId?: string }
214
+ | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
161
215
 
162
216
  export interface ToolExecLike {
163
217
  name: string
@@ -249,15 +303,100 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
249
303
  return inForce !== '' ? inForce : best
250
304
  }
251
305
 
306
+ /**
307
+ * ISSUE 2 (a) — THE RUN A GUARD CALL IS ABOUT, and the one whose tree it may read.
308
+ *
309
+ * THE DEFECT THIS ANSWERS, measured before the fix: `recursive_lock {runId: 'run-b', artifact:
310
+ * '01-as-is.md'}` was REFUSED with `monotonic lock-order: … 00-requirements.md (DRAFT)` — run-A's blocker —
311
+ * while `run-b` had `00-requirements.md` LOCKED and `01-as-is.md` DRAFT, so locking it in run-b was LEGAL.
312
+ * The guard resolved the run from the FILESYSTEM (`resolveRunDir`, i.e. the active/newest run) while the
313
+ * tool resolves it from `args.runId`, so the guard judged a DIFFERENT RUN than the call was about. Under
314
+ * `advisory` the deny was coerced to an allow-with-warning and the tool refused on its own terms, which is
315
+ * why the strict default is what made it bite.
316
+ *
317
+ * SO THE RULE IS: for a LOCK call that NAMES a run, the guard judges THAT RUN. It is the same choice the
318
+ * tool makes, so the two layers cannot disagree about which tree the ordering rule is a property of. A
319
+ * caller that names nothing (every real `write`, and a lock that relies on the active run) is unaffected:
320
+ * the active run still governs, which is what the write-side rules rely on.
321
+ *
322
+ * ⚠ THIS IS SCOPED TO THE LOCK TOOLS DELIBERATELY, and the scope is per rule, not per convenience:
323
+ *
324
+ * - `lock-order` (`recursive_lock*`) — the caller's run WINS. The tool acts on `args.runId`, and the
325
+ * rule is about THAT run's prerequisites, so the guard must not answer for another run. This is the
326
+ * measured defect.
327
+ * - `locked-write` (the write-tool family) — NOT APPLICABLE, by construction: the rule resolves no run
328
+ * at all. It reads the target file's own `Status:` through the path the caller named, so there is no
329
+ * run to prefer and nothing could disagree.
330
+ * - `phase-order` (the write-tool family) — the ACTIVE run KEEPS WINNING, and this function does not
331
+ * touch it. Two reasons, both deliberate: (1) a `write` call carries no run id — no write tool declares
332
+ * one — so consulting `args.runId` here would hand a caller a way to ESCAPE the active run's ordering
333
+ * by naming some other run in an argument the tool ignores; and (2) the rule's declared scope is the
334
+ * run being worked in (it abstains for another run's tree, documented in `phaseOrderRule`), and moving
335
+ * that scope would be a new refusal, not a consistency fix.
336
+ *
337
+ * ⚠ A CALLER-SUPPLIED ID IS A NAME, NEVER A PATH, and it is validated before it can point the guard at
338
+ * anything: the id is trimmed the way `recursive_lock` trims it, then put through `runIdProblem` — the
339
+ * SAME gate the run-id-shaped tools use, which refuses separators, drive specifiers, `..`, a colon, a
340
+ * leading/trailing dot and an over-long name — and finally the resolved directory must sit UNDER this
341
+ * worktree's `<root>/.recursive/run`, the containment rule `runtime.ts` applies to a run directory.
342
+ *
343
+ * An id that fails any of those is NOT USED: the guard falls back to the active run, exactly as it behaved
344
+ * before this change. Falling back (rather than denying) is deliberate: an unusable id is a caller mistake
345
+ * the tool itself refuses (`BAD_RUN_ID` / `Artifact not found`), and inventing a new guard refusal for it
346
+ * would be a second, competing answer to a question `runIdProblem` already owns.
347
+ *
348
+ * A usable id does NOT have to name an EXISTING run: a run with no tree has no unlocked prerequisites, so
349
+ * the ordering rule abstains and the LOCK TOOL still refuses the lock (it checks the artifact exists before
350
+ * anything else). Requiring existence would instead re-introduce the defect in its ugliest form — a refusal
351
+ * built from ANOTHER run's blockers.
352
+ */
353
+ export function resolveGuardRunId(
354
+ name: string,
355
+ args: Record<string, unknown>,
356
+ worktreeRoot: string,
357
+ activeRunId: string,
358
+ ): string {
359
+ if (!LOCK_TOOL_NAMES.has(name) || !worktreeRoot) return activeRunId
360
+ const raw = args.runId
361
+ if (typeof raw !== 'string') return activeRunId
362
+ // The lock tool's own normalisation (`args.runId.trim()`), applied before the shape gate so a padded id
363
+ // is judged as the name the tool will act on, not as the padded string the guard happened to receive.
364
+ const declared = raw.trim()
365
+ if (declared === '' || runIdProblem(declared) !== null) return activeRunId
366
+ // CONTAINMENT. With a shape-valid name the join cannot escape, but the rule is asserted rather than
367
+ // assumed: a future change to the id grammar must not be able to move the guard's read outside the run
368
+ // layer, where the "prerequisites" it found would belong to something else entirely.
369
+ const runRoot = resolve(worktreeRoot, '.recursive', 'run')
370
+ const prefix = runRoot.endsWith(sep) ? runRoot : runRoot + sep
371
+ if (!resolve(join(runRoot, declared)).startsWith(prefix)) return activeRunId
372
+ return declared
373
+ }
374
+
375
+ /**
376
+ * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
377
+ * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
378
+ * literal: a bare call is "the caller had no mode to hand", and the answer to that must
379
+ * be the same posture the config would have produced. Two literals are two defaults, and
380
+ * a helper left on the old `advisory` literal while the config moved to `strict` is
381
+ * exactly the twin-default hole this change closes — a caller that forgot the argument
382
+ * would silently get the permissive branch, which no config could then undo. Every
383
+ * production call site passes the mode explicitly (`index.ts` `runToolGuard`,
384
+ * `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
385
+ * bare caller must not be the one place enforcement quietly turns itself off.
386
+ */
252
387
  export function evaluateToolGuard(
253
388
  exec: ToolExecLike,
254
389
  worktreeRoot: string,
255
390
  activeRunId: string,
256
- mode: EnforcementMode = 'advisory',
391
+ mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
257
392
  ): ToolGuardDecision {
258
393
  const name = exec.name
259
394
  const args = (exec.arguments ?? {}) as Record<string, unknown>
260
- const runId = typeof activeRunId === 'string' ? activeRunId.trim() : ''
395
+ // ⚠ ISSUE 2 — THE RUN IS RESOLVED ONCE, HERE, for the whole call: the policy's phase baseline, the
396
+ // lock-order rule's subject and the decision's own `runId` all read this one answer, so a refusal cannot
397
+ // describe a run the rule did not read. See `resolveGuardRunId` for which rule prefers the caller's run
398
+ // and why the others do not.
399
+ const runId = resolveGuardRunId(name, args, worktreeRoot, typeof activeRunId === 'string' ? activeRunId.trim() : '')
261
400
  const runDir = join(worktreeRoot, '.recursive', 'run', runId)
262
401
 
263
402
  // T15: the transition gate is consulted BEFORE the verdict so its result can
@@ -276,7 +415,10 @@ export function evaluateToolGuard(
276
415
  const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
277
416
  const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
278
417
  const decision = evaluateToolPolicy(policy, name, args, context)
279
- return advisory(verdictFor(mode, decision), transition)
418
+ // The resolved run rides on EVERY decision, including an allow: the guard-decision log records which run
419
+ // a decision was about, and a record that named the ACTIVE run while the rule read another one is exactly
420
+ // the two-answers-in-one-payload defect this pairs with (see `[run: <id>]` in `lockOrderRule`).
421
+ return { ...advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition), runId }
280
422
  }
281
423
 
282
424
  /**
@@ -284,12 +426,35 @@ export function evaluateToolGuard(
284
426
  * `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
285
427
  * decision's `rule` is the label of the rule that decided it, so a policy
286
428
  * verdict is traceable to an auditable line in the policy file.
429
+ *
430
+ * ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
431
+ * person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
432
+ * catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
433
+ * refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
434
+ * default path and the caller got a bare sentence: the recovery options existed in the code and
435
+ * were unreachable in the product, which is worse than the advisory posture they replaced (an
436
+ * advisory `ask` at least surfaced the reason).
437
+ *
438
+ * THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
439
+ * predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
440
+ * "this refusal was decided from an ordering violation" — including a policy FILE whose
441
+ * `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
442
+ * `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
443
+ * here: the blockers arrive from the rule that already resolved them.
444
+ *
445
+ * IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
446
+ * live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
447
+ * `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
448
+ * did not make. One refusal, one ask.
287
449
  */
288
- function verdictFor(mode: EnforcementMode, decision: PolicyDecision): ToolGuardDecision {
450
+ function verdictFor(mode: EnforcementMode, decision: PolicyDecision, artifact: string): ToolGuardDecision {
289
451
  const rule = (decision.rule ?? 'none') as GuardRule
290
452
  if (decision.kind === 'allow') return { kind: 'allow', rule }
291
453
  const reason = decision.reason ?? 'tool policy denied this call'
292
- return mode === 'strict' ? { kind: 'deny', reason, rule } : { kind: 'ask', reason, rule }
454
+ if (mode !== 'strict') return { kind: 'ask', reason, rule }
455
+ const blocked = decision.blockers
456
+ if (blocked === undefined || blocked.length === 0) return { kind: 'deny', reason, rule }
457
+ return { kind: 'deny', reason, rule, ask: buildGateBlockAsk(artifact, reason) }
293
458
  }
294
459
 
295
460
  /**
@@ -337,10 +502,17 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
337
502
  if (transition.passed) return { ...decision, transition }
338
503
  // The verdict stays `allow`; the gate only names itself as the dissenting
339
504
  // voice, so an advisory pass is never silent.
505
+ //
506
+ // ⚠ FU-7 (the log fix) — "REPORT-ONLY", NOT "advisory". This sentence travels into the guard's
507
+ // log line and into the decision a caller reads, and `advisory` there named the GATE's posture
508
+ // — not the configured mode — while the line around it said `tool guard (advisory)`. Under the
509
+ // strict default a reader was told enforcement was off while every gate was strict. What is
510
+ // actually true of this gate in BOTH modes is that it reports and never changes the verdict, so
511
+ // that is what it now says. The warn-on-allow semantics are untouched.
340
512
  return {
341
513
  ...decision,
342
514
  rule: 'transition',
343
- warn: 'transition gate (advisory) failed: ' + transition.failures.join('; '),
515
+ warn: 'transition gate (report-only) failed: ' + transition.failures.join('; '),
344
516
  transition,
345
517
  }
346
518
  }
@@ -350,8 +522,23 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
350
522
  * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
351
523
  * but flags a `warn` so the caller never lets it through unlogged. Non-ask
352
524
  * decisions pass through unchanged.
525
+ *
526
+ * ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
527
+ * neutral branch here. The domain is two postures, one of which ALLOWS the call, so
528
+ * "unspecified" has to be resolved rather than left open, and this codebase's rule for an
529
+ * undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
530
+ * permission*). It therefore FOLLOWS the config default by REFERENCE
531
+ * (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
532
+ * `advisory` literal here would be a second, hidden copy of the old default inside the
533
+ * very module this change moves, and a future caller that omitted the argument would
534
+ * re-open the permissive path with no config able to close it. The production call site
535
+ * (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
536
+ * behaviour — it removes the last place where "we were not told" meant "allow".
353
537
  */
354
- export function coerceAskToDecision(decision: ToolGuardDecision, mode: EnforcementMode = 'advisory'): ToolGuardDecision {
538
+ export function coerceAskToDecision(
539
+ decision: ToolGuardDecision,
540
+ mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
541
+ ): ToolGuardDecision {
355
542
  if (decision.kind !== 'ask') return decision
356
543
  if (mode === 'strict') {
357
544
  return { kind: 'deny', reason: decision.reason ?? 'ask under strict enforcement denies' }
package/src/guard-log.ts CHANGED
@@ -22,11 +22,17 @@
22
22
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
23
  import { dirname, join } from 'node:path'
24
24
  import type { GuardRule } from './enforcement.ts'
25
+ import type { GateBlockAsk } from './recursive_ask.tool.ts'
25
26
 
26
27
  /**
27
28
  * One logged guard decision (the JSONL record shape the board/tests read).
28
29
  * `rule` is always set (the guard's own machine-readable reason for the
29
30
  * verdict); `transition` is present whenever the transition gate was consulted.
31
+ *
32
+ * FU-7: a REFUSAL that a person has to resolve also carries `ask` — the gate-block decision, in the
33
+ * same shape `recursive_lock` attaches to its own refusal. It is recorded because the log is where
34
+ * "why was this lock refused?" is answered, and the options are the other half of that answer; a
35
+ * caller (or a board) reading the trace can act on the refusal without parsing the sentence.
30
36
  */
31
37
  export interface GuardDecisionRecord {
32
38
  at: string
@@ -36,6 +42,7 @@ export interface GuardDecisionRecord {
36
42
  rule: GuardRule
37
43
  reason?: string
38
44
  transition?: { passed: boolean; failures: string[] }
45
+ ask?: GateBlockAsk
39
46
  }
40
47
 
41
48
  /** One logged observed-write tamper (a LOCKED artifact whose hash no longer matches). */