@try-works/dsh-recursive-mode 0.4.8 → 0.5.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.
@@ -1,3 +1,4 @@
1
+ import { type PrerequisiteBlocker } from './lock.ts';
1
2
  /** The three verdicts a rule may carry. */
2
3
  export type Verdict = 'allow' | 'deny' | 'ask';
3
4
  /**
@@ -10,6 +11,18 @@ export interface Decision {
10
11
  kind: Verdict;
11
12
  reason?: string;
12
13
  rule?: string;
14
+ /**
15
+ * THE FACTS THE PREDICATE DECIDED FROM, when it read any. The lock-order rule resolves the
16
+ * artifact's prerequisites from disk, and the layer that turns this decision into a refusal
17
+ * needs those same facts to build the caller's recovery options. Carrying them is what makes
18
+ * "the guard already has them" true: a second `getPrerequisiteBlockers` call from the denial
19
+ * would re-read a run tree that is on disk and unlocked, and could answer differently.
20
+ *
21
+ * Absent for every rule that decides on its pattern alone, and absent when the predicate read
22
+ * nothing — so its presence means "this refusal was decided from these blockers", not "the
23
+ * policy mentions blockers".
24
+ */
25
+ blockers?: readonly PrerequisiteBlocker[];
13
26
  }
14
27
  /**
15
28
  * Extra facts a rule predicate may need. `args` is always the tool call's
@@ -21,6 +34,18 @@ export interface ToolPolicyContext {
21
34
  runDir?: string;
22
35
  runId?: string;
23
36
  worktreeRoot?: string;
37
+ /**
38
+ * The ACTIVE phase artifact of the run — the LOWEST-numbered phase artifact that is not
39
+ * LOCKED, falling back to the highest when every one of them is locked.
40
+ *
41
+ * ⚠ THIS IS NOT A SECOND SELECTOR. It is the answer `currentPhaseArtifact` in
42
+ * `enforcement.ts` produced for this call, carried here so a rule in THIS module can
43
+ * compare against it. `policy-globs.ts` cannot import that function (enforcement.ts
44
+ * imports this module, and the repo has already paid once for a value-level import
45
+ * cycle), so the value is passed in rather than recomputed. A caller that omits it
46
+ * gets NO phase-order verdict — the rule abstains rather than guessing a phase.
47
+ */
48
+ activePhaseArtifact?: string;
24
49
  }
25
50
  /**
26
51
  * What a rule predicate returns when the condition it guards DOES apply:
@@ -37,6 +62,12 @@ export interface ToolPolicyContext {
37
62
  export interface ToolPolicyPredicateMatch {
38
63
  verdict: Verdict;
39
64
  detail?: string;
65
+ /**
66
+ * The blockers the predicate READ, when it read any (see `Decision.blockers`). Optional and
67
+ * additive: a predicate that decided from something else returns none, and the engine's
68
+ * verdict is unchanged either way.
69
+ */
70
+ blockers?: readonly PrerequisiteBlocker[];
40
71
  }
41
72
  export type ToolPolicyPredicate = (id: string, args: Record<string, unknown>, ctx: ToolPolicyContext) => ToolPolicyPredicateMatch | null;
42
73
  export interface ToolPolicyRule {
@@ -152,6 +183,7 @@ export declare const LOCK_TOOL_NAMES: Set<string>;
152
183
  *
153
184
  * `recursive_lock*` -> the monotonic lock-order denial;
154
185
  * the write-tool ids -> the locked-artifact write denial;
186
+ * the write-tool ids -> the phase-order (write-ahead) denial;
155
187
  * `*` -> allow, so an ordinary tool is not turned into an `ask`.
156
188
  *
157
189
  * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
@@ -58,6 +58,43 @@ export declare class AskValidationError extends Error {
58
58
  export declare function validateAskQuestion(question: AskQuestion): AskQuestion;
59
59
  /** Build the question for a gate, validated. */
60
60
  export declare function buildAskQuestion(gateId: AskGateId): AskQuestion;
61
+ /**
62
+ * FU-7 — THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE.
63
+ *
64
+ * A blocked lock is refused by TWO layers. The TOOL refuses when `lockArtifact` throws
65
+ * `Prerequisite blockers:`; the GUARD refuses pre-dispatch when the lock-order rule fires
66
+ * (`monotonic lock-order`), and under the strict default that is the layer the caller meets
67
+ * first. Both must hand the caller the SAME choice, so both build it HERE.
68
+ *
69
+ * ⚠ WHY NOT TWO LITERALS. `fix | reopen | abandon` is the human's way out of a blocked lock,
70
+ * and it existed in exactly one call site (`recursive_lock.tool.ts`). The guard's refusal moved
71
+ * to the default path, and a second hand-written copy of the options there would be a second
72
+ * answer to "what can a person do about this?": the day the options change, one of the two
73
+ * refusals keeps offering the old set and nothing fails. One builder, called from both.
74
+ *
75
+ * `blocked` is the refusal's OWN sentence — the guard's rule text or the tool's exception
76
+ * message — carried as data so the payload says what it is about without a reader having to
77
+ * match it against the text beside it.
78
+ */
79
+ export interface GateBlockAsk extends AskQuestion {
80
+ gate: 'gate-block';
81
+ artifact: string;
82
+ blocked: string;
83
+ }
84
+ /** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
85
+ export declare function buildGateBlockAsk(artifact: string, blocked: string): GateBlockAsk;
86
+ /**
87
+ * FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
88
+ *
89
+ * WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
90
+ * `Error: <reason>` and drops every other field of the decision (measured in
91
+ * `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
92
+ * ask that rode along as a SIBLING field would reach the model as nothing at all — which is
93
+ * exactly how a strict-by-default guard made the recovery path unreachable. The refusal
94
+ * therefore renders the payload into the text it hands back, and it renders THIS object, so
95
+ * the visible sentence and the structured payload cannot disagree.
96
+ */
97
+ export declare function renderGateBlockAsk(ask: GateBlockAsk): string;
61
98
  /**
62
99
  * PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
63
100
  *
package/lib/runtime.d.ts CHANGED
@@ -743,7 +743,7 @@ export declare class RecursiveRuntime extends Service {
743
743
  code: string;
744
744
  message: string;
745
745
  }): boolean;
746
- /** Phase C R7: resolve the enforcement config (strict|advisory, default advisory). */
746
+ /** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
747
747
  get enforcementConfig(): EnforcementConfig;
748
748
  setEnforcementConfig(config: unknown): EnforcementConfig;
749
749
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-recursive-mode",
3
3
  "description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
4
- "version": "0.4.8",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -166,7 +166,17 @@ async function main() {
166
166
  const coercedAdvisory = coerceAskToDecision(askDecision, 'advisory')
167
167
  check('T6 ask->deny under strict', coercedStrict.kind === 'deny')
168
168
  check('T6 ask->allow+warn under advisory (never silent)', coercedAdvisory.kind === 'allow' && typeof (coercedAdvisory as { warn?: string }).warn === 'string')
169
- check('R7 config default advisory', JSON.stringify(resolveEnforcementConfig(undefined)) === JSON.stringify(DEFAULT_ENFORCEMENT))
169
+ // ⚠ THE CHECK THAT USED TO BE HERE WAS VACUOUS: it compared the resolver's output with
170
+ // `DEFAULT_ENFORCEMENT`, and both sides move together, so a change of default could never
171
+ // fail it. Read the default and assert the VALUE, so a revert to advisory is caught.
172
+ const defaultConfig = resolveEnforcementConfig(undefined)
173
+ check('R7 config default strict (all three gates)',
174
+ defaultConfig.preStep === 'strict' && defaultConfig.toolGuards === 'strict' && defaultConfig.tamper === 'strict'
175
+ && JSON.stringify(defaultConfig) === JSON.stringify(DEFAULT_ENFORCEMENT))
176
+ // A PARTIAL section must fill the unstated gates with the same posture, not with advisory:
177
+ // the settings service merges ONE edited field into an entry's config.
178
+ const partial = resolveEnforcementConfig({ toolGuards: 'advisory' })
179
+ check('R7 partial config fills strict', partial.preStep === 'strict' && partial.tamper === 'strict' && partial.toolGuards === 'advisory')
170
180
  let configError = ''
171
181
  try { resolveEnforcementConfig({ bogus: 1 }) } catch (err) { configError = (err as Error).message }
172
182
  check('R7 unknown config key fails', configError.includes('unknown key'))
package/src/config.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * silently coerced a bad value would be a second, weaker contract beside the real one.
18
18
  */
19
19
  import z from '@deepseek-ai/schemastery'
20
- import { DEFAULT_BUDGETS } from './enforcement.ts'
20
+ import { DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT_MODE } from './enforcement.ts'
21
21
 
22
22
  /** One enforcement mode, as the settings form presents it. */
23
23
  const enforcementMode = z.union([z.const('strict'), z.const('advisory')])
@@ -58,14 +58,21 @@ export const Config = z.object({
58
58
  repoRoot: z.string().description(
59
59
  'Control-plane root. Defaults to the process working directory when unset.',
60
60
  ),
61
+ /**
62
+ * ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
63
+ * that is deliberate: the schema default and the runtime default are the SAME value, and
64
+ * two literals here would be two defaults. A caller that omits the section gets
65
+ * `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
66
+ * the resolver's fill. Both must be the enforcing posture — see the const for why.
67
+ */
61
68
  enforcement: z.object({
62
- preStep: enforcementMode.default('advisory').description(
69
+ preStep: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
63
70
  'Phase pre-step enforcement: strict refuses an out-of-order transition, advisory warns and proceeds.',
64
71
  ),
65
- toolGuards: enforcementMode.default('advisory').description(
72
+ toolGuards: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
66
73
  'Tool guard mode: strict DENIES an out-of-order tool call, advisory allows it and carries the warning.',
67
74
  ),
68
- tamper: enforcementMode.default('advisory').description(
75
+ tamper: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
69
76
  'Tamper detection: strict refuses an artifact whose LockHash no longer matches its body.',
70
77
  ),
71
78
  budgets: z.object({
@@ -3,7 +3,7 @@
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'
@@ -15,6 +15,7 @@ import {
15
15
  type ToolPolicy, type ToolPolicyContext, type Decision as PolicyDecision,
16
16
  } from './policy-globs.ts'
17
17
  import { withPhaseBaseline, phaseNumberForArtifact, resolveFrom } from './phase-rules.ts'
18
+ import { buildGateBlockAsk, type GateBlockAsk } from './recursive_ask.tool.ts'
18
19
 
19
20
  /**
20
21
  * The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
@@ -75,6 +76,29 @@ const BUDGET_KEYS: ReadonlyArray<keyof BudgetConfig> = [
75
76
  'maxAuditRounds', 'maxRepairAttempts', 'maxDelegationDepth', 'maxChildrenPerPhase', 'maxResultBytes',
76
77
  ]
77
78
 
79
+ /**
80
+ * THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
81
+ *
82
+ * The owner's rule is *only one phase may be active at a time, and the phases should be
83
+ * sequential and the active phase must be locked before proceeding to next phase*. In
84
+ * `advisory` that rule is only WARNED about, and a live run showed what that costs: the
85
+ * run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
86
+ * nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
87
+ * a default because it also refused the run's OWN artifacts — a false positive. That was
88
+ * fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
89
+ * active artifact stays writable while a later one is refused. Strict therefore refuses
90
+ * exactly the ordering violations it is meant to refuse, so the default is the enforcing
91
+ * posture rather than a warning nobody has to act on.
92
+ *
93
+ * ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
94
+ * project's recurring failure: the same value exists in the Config schema, in
95
+ * `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
96
+ * the helpers below, and moving only some of them leaves a caller that "still gets
97
+ * advisory". Every one of those sites now reads THIS const, so a revert is a one-line
98
+ * change and nothing can drift from it.
99
+ */
100
+ export const DEFAULT_ENFORCEMENT_MODE: EnforcementMode = 'strict'
101
+
78
102
  /**
79
103
  * Validate the enforcement config shape (unknown keys fail at plugin load).
80
104
  *
@@ -89,7 +113,17 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
89
113
  if (unknown.length > 0) {
90
114
  throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper, budgets }')
91
115
  }
92
- const mode = (value: unknown): EnforcementMode => (value === 'strict' ? 'strict' : 'advisory')
116
+ // ⚠ AN ABSENT MODE RESOLVES TO THE DEFAULT MODE, not to the permissive branch. This is
117
+ // the twin-default trap in its most consequential form: a caller that supplies a PARTIAL
118
+ // section — `enforcement: { toolGuards: 'advisory' }` from a settings patch, or just the
119
+ // budgets — leaves the other gates unstated, and filling those with `advisory` would
120
+ // hand back a config that is looser than the plugin's own default with nothing saying so.
121
+ // An UNRECOGNIZED value resolves the same way and thus fails CLOSED. The Config schema in
122
+ // src/config.ts still rejects a typo loudly at the settings boundary; this resolver is
123
+ // the lenient one, and a lenient resolver must bend towards the safe posture: a typo that
124
+ // blocks is a visible stop, a typo that permits is the hour-long out-of-order run again.
125
+ const mode = (value: unknown): EnforcementMode =>
126
+ value === 'strict' ? 'strict' : value === 'advisory' ? 'advisory' : DEFAULT_ENFORCEMENT_MODE
93
127
 
94
128
  const rawBudgets = (raw.budgets ?? {}) as Record<string, unknown>
95
129
  if (typeof raw.budgets !== 'undefined' && (raw.budgets === null || typeof raw.budgets !== 'object')) {
@@ -117,10 +151,16 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
117
151
  }
118
152
  }
119
153
 
154
+ /**
155
+ * The runtime default: what a caller gets when it supplies no `enforcement` section at all
156
+ * (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
157
+ * `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
158
+ * see that const for WHY the default is strict.
159
+ */
120
160
  export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
121
- preStep: 'advisory',
122
- toolGuards: 'advisory',
123
- tamper: 'advisory',
161
+ preStep: DEFAULT_ENFORCEMENT_MODE,
162
+ toolGuards: DEFAULT_ENFORCEMENT_MODE,
163
+ tamper: DEFAULT_ENFORCEMENT_MODE,
124
164
  budgets: DEFAULT_BUDGETS,
125
165
  }
126
166
 
@@ -129,8 +169,14 @@ export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
129
169
  * every allow/deny/ask the guard hands back). `'none'` means no guard predicate
130
170
  * fired; `'transition'` marks a decision whose only dissenting gate was the
131
171
  * advisory transition gate (see consultTransitionGate).
172
+ *
173
+ * `'phase-order'` is the WRITE half of the ordering rule the owner states as *only
174
+ * one phase may be active at a time, and the phases should be sequential and the
175
+ * active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
176
+ * locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
177
+ * because the guard log has to tell the owner which of the two the agent attempted.
132
178
  */
133
- export type GuardRule = 'lock-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
179
+ export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
134
180
 
135
181
  /** T15: the transition gate's verdict as attached to a decision (advisory only). */
136
182
  export interface GuardTransition {
@@ -147,10 +193,16 @@ export interface GuardTransition {
147
193
  * optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
148
194
  * (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
149
195
  * grow extra keys. `evaluateToolGuard` itself always sets `rule`.
196
+ *
197
+ * ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
198
+ * PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
199
+ * and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
200
+ * refusals cannot offer different options. It is absent on every decision that is not a lock-order
201
+ * refusal decided from real blockers, which is why every reader must treat it as optional.
150
202
  */
151
203
  export type ToolGuardDecision =
152
204
  | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
153
- | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition }
205
+ | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk }
154
206
  | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
155
207
 
156
208
  export interface ToolExecLike {
@@ -179,9 +231,12 @@ export interface ToolExecLike {
179
231
  * `recursive:policy` later — can read the effective rules instead of inferring
180
232
  * them from a code path.
181
233
  */
182
- export function resolveToolPolicyForGuard(worktreeRoot: string, runId: string): ToolPolicy {
234
+ export function resolveToolPolicyForGuard(worktreeRoot: string, runId: string, activePhaseArtifact?: string): ToolPolicy {
183
235
  const loaded = loadToolPolicyFile(worktreeRoot)
184
- return withPhaseBaseline(loaded.policy, currentPhaseArtifact(worktreeRoot, runId))
236
+ // A caller that already asked `currentPhaseArtifact` for this call passes the answer in,
237
+ // so one guard call reads the run tree's lock statuses ONCE rather than twice. The
238
+ // no-cache discipline is unchanged: an omitted argument still reads the filesystem here.
239
+ return withPhaseBaseline(loaded.policy, activePhaseArtifact ?? currentPhaseArtifact(worktreeRoot, runId))
185
240
  }
186
241
 
187
242
  /**
@@ -240,11 +295,23 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
240
295
  return inForce !== '' ? inForce : best
241
296
  }
242
297
 
298
+ /**
299
+ * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
300
+ * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
301
+ * literal: a bare call is "the caller had no mode to hand", and the answer to that must
302
+ * be the same posture the config would have produced. Two literals are two defaults, and
303
+ * a helper left on the old `advisory` literal while the config moved to `strict` is
304
+ * exactly the twin-default hole this change closes — a caller that forgot the argument
305
+ * would silently get the permissive branch, which no config could then undo. Every
306
+ * production call site passes the mode explicitly (`index.ts` `runToolGuard`,
307
+ * `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
308
+ * bare caller must not be the one place enforcement quietly turns itself off.
309
+ */
243
310
  export function evaluateToolGuard(
244
311
  exec: ToolExecLike,
245
312
  worktreeRoot: string,
246
313
  activeRunId: string,
247
- mode: EnforcementMode = 'advisory',
314
+ mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
248
315
  ): ToolGuardDecision {
249
316
  const name = exec.name
250
317
  const args = (exec.arguments ?? {}) as Record<string, unknown>
@@ -258,10 +325,16 @@ export function evaluateToolGuard(
258
325
  // T16: the verdict comes from the ordered policy, not from branches here. The
259
326
  // policy file is re-read per call on purpose: a policy a human just edited
260
327
  // must take effect on the next tool call, not after a restart.
261
- const policy = resolveToolPolicyForGuard(worktreeRoot, runId)
262
- const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot }
328
+ //
329
+ // The ACTIVE phase is resolved ONCE, by the ONE selector, and is used twice: it
330
+ // selects the phase baseline (narrowing rules) and it is carried into the context so
331
+ // the phase-order rule can refuse a WRITE that is ahead of the active phase. Both
332
+ // halves of the ordering rule therefore read the same answer for the same call.
333
+ const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId)
334
+ const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
335
+ const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
263
336
  const decision = evaluateToolPolicy(policy, name, args, context)
264
- return advisory(verdictFor(mode, decision), transition)
337
+ return advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition)
265
338
  }
266
339
 
267
340
  /**
@@ -269,12 +342,35 @@ export function evaluateToolGuard(
269
342
  * `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
270
343
  * decision's `rule` is the label of the rule that decided it, so a policy
271
344
  * verdict is traceable to an auditable line in the policy file.
345
+ *
346
+ * ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
347
+ * person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
348
+ * catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
349
+ * refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
350
+ * default path and the caller got a bare sentence: the recovery options existed in the code and
351
+ * were unreachable in the product, which is worse than the advisory posture they replaced (an
352
+ * advisory `ask` at least surfaced the reason).
353
+ *
354
+ * THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
355
+ * predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
356
+ * "this refusal was decided from an ordering violation" — including a policy FILE whose
357
+ * `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
358
+ * `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
359
+ * here: the blockers arrive from the rule that already resolved them.
360
+ *
361
+ * IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
362
+ * live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
363
+ * `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
364
+ * did not make. One refusal, one ask.
272
365
  */
273
- function verdictFor(mode: EnforcementMode, decision: PolicyDecision): ToolGuardDecision {
366
+ function verdictFor(mode: EnforcementMode, decision: PolicyDecision, artifact: string): ToolGuardDecision {
274
367
  const rule = (decision.rule ?? 'none') as GuardRule
275
368
  if (decision.kind === 'allow') return { kind: 'allow', rule }
276
369
  const reason = decision.reason ?? 'tool policy denied this call'
277
- return mode === 'strict' ? { kind: 'deny', reason, rule } : { kind: 'ask', reason, rule }
370
+ if (mode !== 'strict') return { kind: 'ask', reason, rule }
371
+ const blocked = decision.blockers
372
+ if (blocked === undefined || blocked.length === 0) return { kind: 'deny', reason, rule }
373
+ return { kind: 'deny', reason, rule, ask: buildGateBlockAsk(artifact, reason) }
278
374
  }
279
375
 
280
376
  /**
@@ -322,10 +418,17 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
322
418
  if (transition.passed) return { ...decision, transition }
323
419
  // The verdict stays `allow`; the gate only names itself as the dissenting
324
420
  // voice, so an advisory pass is never silent.
421
+ //
422
+ // ⚠ FU-7 (the log fix) — "REPORT-ONLY", NOT "advisory". This sentence travels into the guard's
423
+ // log line and into the decision a caller reads, and `advisory` there named the GATE's posture
424
+ // — not the configured mode — while the line around it said `tool guard (advisory)`. Under the
425
+ // strict default a reader was told enforcement was off while every gate was strict. What is
426
+ // actually true of this gate in BOTH modes is that it reports and never changes the verdict, so
427
+ // that is what it now says. The warn-on-allow semantics are untouched.
325
428
  return {
326
429
  ...decision,
327
430
  rule: 'transition',
328
- warn: 'transition gate (advisory) failed: ' + transition.failures.join('; '),
431
+ warn: 'transition gate (report-only) failed: ' + transition.failures.join('; '),
329
432
  transition,
330
433
  }
331
434
  }
@@ -335,8 +438,23 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
335
438
  * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
336
439
  * but flags a `warn` so the caller never lets it through unlogged. Non-ask
337
440
  * decisions pass through unchanged.
441
+ *
442
+ * ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
443
+ * neutral branch here. The domain is two postures, one of which ALLOWS the call, so
444
+ * "unspecified" has to be resolved rather than left open, and this codebase's rule for an
445
+ * undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
446
+ * permission*). It therefore FOLLOWS the config default by REFERENCE
447
+ * (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
448
+ * `advisory` literal here would be a second, hidden copy of the old default inside the
449
+ * very module this change moves, and a future caller that omitted the argument would
450
+ * re-open the permissive path with no config able to close it. The production call site
451
+ * (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
452
+ * behaviour — it removes the last place where "we were not told" meant "allow".
338
453
  */
339
- export function coerceAskToDecision(decision: ToolGuardDecision, mode: EnforcementMode = 'advisory'): ToolGuardDecision {
454
+ export function coerceAskToDecision(
455
+ decision: ToolGuardDecision,
456
+ mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
457
+ ): ToolGuardDecision {
340
458
  if (decision.kind !== 'ask') return decision
341
459
  if (mode === 'strict') {
342
460
  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). */
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
22
22
  import { createRecursiveReviewTool } from './recursive_review.tool.ts'
23
23
  import { createRecursiveDelegateTool } from './recursive_delegate.tool.ts'
24
24
  import { createRecursiveAskTool } from './recursive_ask.tool.ts'
25
+ import { renderGateBlockAsk } from './recursive_ask.tool.ts'
25
26
  import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
26
27
  import type { SubagentsRuntimeLike } from './delegation.ts'
27
28
  import { registerRecursiveCommand } from './commands.ts'
@@ -187,6 +188,9 @@ function runToolGuard(
187
188
  record.reason = final.reason
188
189
  }
189
190
  if (final.transition) record.transition = final.transition
191
+ // FU-7: a refusal a person has to resolve carries its options into the trace too, so the
192
+ // question "why was this lock refused?" and the answer to it are read from one record.
193
+ if (final.kind === 'deny' && final.ask) record.ask = final.ask
190
194
  appendGuardDecision(root, record)
191
195
  }
192
196
  return final
@@ -512,10 +516,29 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
512
516
  return { kind: 'deny', reason: 'the tool guard produced no decision' }
513
517
  }
514
518
  // The guard's own object, returned VERBATIM — the pinned contract.
515
- if (final.kind === 'deny') return final
519
+ //
520
+ // ⚠ EXCEPT THAT A DENIAL'S `ask` MUST BE CARRIED IN THE TEXT, and this is the one place the
521
+ // plugin can do it. Measured in the harness (`packages/core/tools`): a `tools/pre-execute`
522
+ // deny becomes `content: [{ type: 'text', text: 'Error: ' + reason }]` and EVERY other field
523
+ // of the decision is dropped, so the gate-block payload added for FU-7 would have reached the
524
+ // model as nothing at all — which is precisely the defect: a strict-by-default guard refusing
525
+ // a lock with a bare sentence, while `fix | reopen | abandon` was how the run got unblocked.
526
+ // The sentence is rendered FROM the payload (`renderGateBlockAsk`), so what the caller reads
527
+ // and what the decision carries cannot drift; the plain reason stays first and intact, so a
528
+ // caller that ignores the ask still gets the rule name and the blocking artifact.
529
+ if (final.kind === 'deny') {
530
+ return final.ask === undefined
531
+ ? final
532
+ : { ...final, reason: final.reason + ' ' + renderGateBlockAsk(final.ask) }
533
+ }
516
534
  if (final.kind === 'allow' && final.warn) {
517
- // Package-tagged host logging; never a silent pass under approval=never.
518
- console.warn('[recursive] tool guard (advisory): ' + final.warn + ' — allowing')
535
+ // ⚠ IT NAMES THE MODE IT ACTUALLY RAN UNDER. This line used to begin `tool guard (advisory)`
536
+ // unconditionally, while the warning it carries comes from the transition gate's REPORT-ONLY
537
+ // consult — which attaches a warning to an ALLOW in BOTH modes. Under the strict default the
538
+ // line therefore told a reader that enforcement was off while every gate was strict: text
539
+ // asserting a state that was not so. The mode is read from the same config the guard ran
540
+ // under, so the prefix moves with the setting; the warn semantics are unchanged.
541
+ console.warn('[recursive] tool guard (' + recursive.enforcementConfig.toolGuards + ') allowed this call: ' + final.warn)
519
542
  }
520
543
  return typeof next === 'function' ? next() : { kind: 'allow' }
521
544
  }))