@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.
- package/lib/config.d.ts +14 -0
- package/lib/enforcement.d.ts +68 -2
- package/lib/guard-log.d.ts +7 -0
- package/lib/index.js +250 -37
- package/lib/policy-globs.d.ts +32 -0
- package/lib/recursive_ask.tool.d.ts +37 -0
- package/lib/runtime.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +11 -1
- package/src/config.ts +11 -4
- package/src/enforcement.ts +135 -17
- package/src/guard-log.ts +7 -0
- package/src/index.ts +26 -3
- package/src/policy-globs.ts +160 -7
- package/src/policy.ts +1 -0
- package/src/recursive_ask.tool.ts +48 -0
- package/src/recursive_lock.tool.ts +8 -2
- package/src/runtime.ts +1 -1
package/src/policy-globs.ts
CHANGED
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
* just wrote and believes is in force.
|
|
41
41
|
*/
|
|
42
42
|
import { existsSync, readFileSync } from 'node:fs'
|
|
43
|
-
import { join } from 'node:path'
|
|
44
|
-
import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
|
|
43
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
44
|
+
import { getLockStatus, getPrerequisiteBlockers, type PrerequisiteBlocker } from './lock.ts'
|
|
45
45
|
import { getMdFieldValue } from './status.ts'
|
|
46
|
-
import { policyTargetPath, resolveFrom } from './phase-rules.ts'
|
|
46
|
+
import { phaseNumberForArtifact, policyTargetPath, resolveFrom } from './phase-rules.ts'
|
|
47
47
|
|
|
48
48
|
/** The three verdicts a rule may carry. */
|
|
49
49
|
export type Verdict = 'allow' | 'deny' | 'ask'
|
|
@@ -58,6 +58,18 @@ export interface Decision {
|
|
|
58
58
|
kind: Verdict
|
|
59
59
|
reason?: string
|
|
60
60
|
rule?: string
|
|
61
|
+
/**
|
|
62
|
+
* THE FACTS THE PREDICATE DECIDED FROM, when it read any. The lock-order rule resolves the
|
|
63
|
+
* artifact's prerequisites from disk, and the layer that turns this decision into a refusal
|
|
64
|
+
* needs those same facts to build the caller's recovery options. Carrying them is what makes
|
|
65
|
+
* "the guard already has them" true: a second `getPrerequisiteBlockers` call from the denial
|
|
66
|
+
* would re-read a run tree that is on disk and unlocked, and could answer differently.
|
|
67
|
+
*
|
|
68
|
+
* Absent for every rule that decides on its pattern alone, and absent when the predicate read
|
|
69
|
+
* nothing — so its presence means "this refusal was decided from these blockers", not "the
|
|
70
|
+
* policy mentions blockers".
|
|
71
|
+
*/
|
|
72
|
+
blockers?: readonly PrerequisiteBlocker[]
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -70,6 +82,18 @@ export interface ToolPolicyContext {
|
|
|
70
82
|
runDir?: string
|
|
71
83
|
runId?: string
|
|
72
84
|
worktreeRoot?: string
|
|
85
|
+
/**
|
|
86
|
+
* The ACTIVE phase artifact of the run — the LOWEST-numbered phase artifact that is not
|
|
87
|
+
* LOCKED, falling back to the highest when every one of them is locked.
|
|
88
|
+
*
|
|
89
|
+
* ⚠ THIS IS NOT A SECOND SELECTOR. It is the answer `currentPhaseArtifact` in
|
|
90
|
+
* `enforcement.ts` produced for this call, carried here so a rule in THIS module can
|
|
91
|
+
* compare against it. `policy-globs.ts` cannot import that function (enforcement.ts
|
|
92
|
+
* imports this module, and the repo has already paid once for a value-level import
|
|
93
|
+
* cycle), so the value is passed in rather than recomputed. A caller that omits it
|
|
94
|
+
* gets NO phase-order verdict — the rule abstains rather than guessing a phase.
|
|
95
|
+
*/
|
|
96
|
+
activePhaseArtifact?: string
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
/**
|
|
@@ -87,6 +111,12 @@ export interface ToolPolicyContext {
|
|
|
87
111
|
export interface ToolPolicyPredicateMatch {
|
|
88
112
|
verdict: Verdict
|
|
89
113
|
detail?: string
|
|
114
|
+
/**
|
|
115
|
+
* The blockers the predicate READ, when it read any (see `Decision.blockers`). Optional and
|
|
116
|
+
* additive: a predicate that decided from something else returns none, and the engine's
|
|
117
|
+
* verdict is unchanged either way.
|
|
118
|
+
*/
|
|
119
|
+
blockers?: readonly PrerequisiteBlocker[]
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
export type ToolPolicyPredicate = (
|
|
@@ -326,7 +356,7 @@ export function evaluateToolPolicy(
|
|
|
326
356
|
// Abstention: this rule does not govern this call, so it does not
|
|
327
357
|
// participate and the next rule in precedence order decides.
|
|
328
358
|
if (match === null) continue
|
|
329
|
-
return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason)
|
|
359
|
+
return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason, match.blockers)
|
|
330
360
|
}
|
|
331
361
|
return decide(rule, rule.verdict, rule.reason)
|
|
332
362
|
}
|
|
@@ -335,10 +365,17 @@ export function evaluateToolPolicy(
|
|
|
335
365
|
return { kind: 'ask', reason: 'no policy rule matches ' + id + ' - ask is the no-match default' }
|
|
336
366
|
}
|
|
337
367
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
368
|
+
/**
|
|
369
|
+
* One place where a rule's verdict becomes a decision, so `label` cannot drift.
|
|
370
|
+
*
|
|
371
|
+
* `blockers` rides along untouched when the predicate supplied any (see `Decision.blockers`);
|
|
372
|
+
* an EMPTY list is dropped rather than carried, so `blockers` on a decision always means "there
|
|
373
|
+
* were blockers", never "the rule looked and found none".
|
|
374
|
+
*/
|
|
375
|
+
function decide(rule: ToolPolicyRule, kind: Verdict, reason: string, blockers?: readonly PrerequisiteBlocker[]): Decision {
|
|
340
376
|
const decision: Decision = { kind, reason }
|
|
341
377
|
if (rule.label) decision.rule = rule.label
|
|
378
|
+
if (blockers !== undefined && blockers.length > 0) decision.blockers = blockers
|
|
342
379
|
return decision
|
|
343
380
|
}
|
|
344
381
|
|
|
@@ -413,7 +450,14 @@ function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolic
|
|
|
413
450
|
if (!name || !runDir) return null
|
|
414
451
|
const blockers = getPrerequisiteBlockers(runDir, name)
|
|
415
452
|
if (blockers.length === 0) return null
|
|
416
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
verdict: 'deny',
|
|
455
|
+
detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', '),
|
|
456
|
+
// THE SAME READ, carried up rather than thrown away: the denial's recovery options are built
|
|
457
|
+
// from these blockers, and re-deriving them one layer higher would be a second filesystem
|
|
458
|
+
// answer to a question this rule has already answered.
|
|
459
|
+
blockers,
|
|
460
|
+
}
|
|
417
461
|
}
|
|
418
462
|
|
|
419
463
|
/**
|
|
@@ -452,12 +496,95 @@ function lockedWriteRule(target: string | null, worktreeRoot: string | undefined
|
|
|
452
496
|
return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
|
|
453
497
|
}
|
|
454
498
|
|
|
499
|
+
/**
|
|
500
|
+
* The file name when `abs` is a DIRECT CHILD of `runDir`, and `null` otherwise.
|
|
501
|
+
*
|
|
502
|
+
* ⚠ THE DIRECT-CHILD TEST IS NOT TIDINESS — IT IS WHAT KEEPS THE RULE OFF THE SUPPORT
|
|
503
|
+
* FILES. `phaseNumberForArtifact` reads the leading digits of a NAME, so
|
|
504
|
+
* `<run>/evidence/01-as-is.md` or `<run>/subagents/child/03-brief.md` would look like
|
|
505
|
+
* phase 1 and phase 3 artifacts if the name were all that was examined. A phase artifact
|
|
506
|
+
* is a file the run tree holds DIRECTLY beside the others (`recursive_init` writes all
|
|
507
|
+
* twelve into `<run>/` itself), so the parent directory is part of the definition.
|
|
508
|
+
*
|
|
509
|
+
* The comparison normalizes separators and case: the same run directory reached through
|
|
510
|
+
* a Windows spelling that differs in case is the same directory, and the rule must not
|
|
511
|
+
* abstain on one spelling and fire on the other.
|
|
512
|
+
*/
|
|
513
|
+
function directChildName(abs: string, runDir: string): string | null {
|
|
514
|
+
const normalize = (path: string) => resolve(path).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
515
|
+
if (normalize(dirname(abs)) !== normalize(runDir)) return null
|
|
516
|
+
return basename(abs)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* PHASE-ORDER rule: a denial when the target is a LATER phase's artifact than the phase
|
|
521
|
+
* currently active, `null` when it is not.
|
|
522
|
+
*
|
|
523
|
+
* ⚠ THE HOLE THIS CLOSES. The monotonic rule was enforced on `recursive_lock` ONLY. An
|
|
524
|
+
* agent could therefore write `08-memory-impact.md` while the run sat at phase 0 — and a
|
|
525
|
+
* live run did exactly that: twelve artifacts, not one of them locked, written out of
|
|
526
|
+
* order, with a single line in `operations/operations.jsonl`. Ordering that only binds
|
|
527
|
+
* the lock tool is not ordering; the model's ordinary `write` is the path that mattered.
|
|
528
|
+
*
|
|
529
|
+
* The owner's rule, verbatim: *only one phase may be active at a time, and the phases
|
|
530
|
+
* should be sequential and the active phase must be locked before proceeding to next
|
|
531
|
+
* phase*. The ACTIVE phase is the one the selector names (`ctx.activePhaseArtifact`), so:
|
|
532
|
+
*
|
|
533
|
+
* - the target is the ACTIVE artifact, or shares its phase number (`00-requirements.md`
|
|
534
|
+
* and `00-worktree.md` are both phase 0; `01-as-is.md` and `01.5-root-cause.md` are
|
|
535
|
+
* both phase 1) -> ABSTAIN, the write is allowed;
|
|
536
|
+
* - the target is an EARLIER phase -> ABSTAIN. Such an artifact is LOCKED by
|
|
537
|
+
* construction (the active phase is the lowest UNLOCKED one), so the locked-artifact
|
|
538
|
+
* rule above decides it, and its rule label is preserved;
|
|
539
|
+
* - the target is a LATER phase -> DENY: working ahead.
|
|
540
|
+
*
|
|
541
|
+
* ⚠ THE ALLOW HALF IS LOAD-BEARING. An enforcement rule in this exact area was once the
|
|
542
|
+
* bug: strict enforcement denied the run's OWN artifacts in every phase and made the
|
|
543
|
+
* workflow unusable (see `resolveFrom` and `currentPhaseArtifact`). "The active artifact
|
|
544
|
+
* stays writable at every phase" is therefore asserted by walking every phase, not by
|
|
545
|
+
* one case — `tests/strict-run-tree.spec.ts` (d).
|
|
546
|
+
*
|
|
547
|
+
* WHAT IT ABSTAINS ON, deliberately:
|
|
548
|
+
* - no `activePhaseArtifact` (a caller with no run context, or a run with no phase
|
|
549
|
+
* artifacts yet) -> abstain, never guess a phase;
|
|
550
|
+
* - a target outside the active run's own directory -> abstain. The rule is about THIS
|
|
551
|
+
* run's sequence; another run's tree is a different question and denying it here
|
|
552
|
+
* would be a false positive;
|
|
553
|
+
* - a support file (anything not a direct child) -> abstain: `evidence/`, `scratch/`,
|
|
554
|
+
* `addenda/`, `subagents/`, `operations/` and a plain `<run>/notes.md` are not phases.
|
|
555
|
+
*
|
|
556
|
+
* The predicate adds only the per-call particular — which artifact, which phase, which is
|
|
557
|
+
* active; the rule keeps the static, auditable sentence.
|
|
558
|
+
*/
|
|
559
|
+
function phaseOrderRule(target: string | null, ctx: ToolPolicyContext): ToolPolicyPredicateMatch | null {
|
|
560
|
+
const active = ctx.activePhaseArtifact
|
|
561
|
+
if (!target || !ctx.runDir || !ctx.worktreeRoot) return null
|
|
562
|
+
if (typeof active !== 'string' || active === '') return null
|
|
563
|
+
const activePhaseText = phaseNumberForArtifact(active)
|
|
564
|
+
if (!activePhaseText) return null
|
|
565
|
+
const normalized = target.replace(/\\/g, '/')
|
|
566
|
+
if (!normalized.endsWith('.md')) return null
|
|
567
|
+
const abs = resolveFrom(ctx.worktreeRoot, normalized)
|
|
568
|
+
if (!abs) return null
|
|
569
|
+
const name = directChildName(abs, ctx.runDir)
|
|
570
|
+
if (name === null) return null
|
|
571
|
+
const phaseText = phaseNumberForArtifact(name)
|
|
572
|
+
if (!phaseText) return null
|
|
573
|
+
if (Number(phaseText) <= Number(activePhaseText)) return null
|
|
574
|
+
return {
|
|
575
|
+
verdict: 'deny',
|
|
576
|
+
detail: name + ' is phase ' + phaseText + ' but the ACTIVE phase is ' + active
|
|
577
|
+
+ ' (phase ' + activePhaseText + ') - the active phase must be locked before writing a later phase',
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
455
581
|
/**
|
|
456
582
|
* The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
|
|
457
583
|
* data:
|
|
458
584
|
*
|
|
459
585
|
* `recursive_lock*` -> the monotonic lock-order denial;
|
|
460
586
|
* the write-tool ids -> the locked-artifact write denial;
|
|
587
|
+
* the write-tool ids -> the phase-order (write-ahead) denial;
|
|
461
588
|
* `*` -> allow, so an ordinary tool is not turned into an `ask`.
|
|
462
589
|
*
|
|
463
590
|
* Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
|
|
@@ -488,6 +615,20 @@ export function builtInToolPolicyRules(): ToolPolicyRule[] {
|
|
|
488
615
|
predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null),
|
|
489
616
|
})
|
|
490
617
|
}
|
|
618
|
+
// The SAME write-tool ids get a SECOND conditional deny, the phase-order rule. Two rules
|
|
619
|
+
// share one pattern on purpose: the engine's predicates ABSTAIN (`null`) when their
|
|
620
|
+
// condition does not apply, so a clean write falls through both to the catch-all allow,
|
|
621
|
+
// and a target that is BOTH locked and a later phase is reported by the locked rule
|
|
622
|
+
// first (file order within a specificity tier), which is the pre-existing wording.
|
|
623
|
+
for (const name of WRITE_TOOL_NAMES) {
|
|
624
|
+
rules.push({
|
|
625
|
+
pattern: name,
|
|
626
|
+
verdict: 'deny',
|
|
627
|
+
reason: 'phase order: only one phase may be active at a time - the active phase must be locked before a later phase artifact is written',
|
|
628
|
+
label: 'phase-order',
|
|
629
|
+
predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null),
|
|
630
|
+
})
|
|
631
|
+
}
|
|
491
632
|
rules.push({
|
|
492
633
|
pattern: '*',
|
|
493
634
|
verdict: 'allow',
|
|
@@ -523,6 +664,18 @@ export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
|
|
|
523
664
|
if (rule.pattern === 'recursive_lock*') {
|
|
524
665
|
return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null) }
|
|
525
666
|
}
|
|
667
|
+
// ⚠ THE LABEL IS CONSULTED BEFORE THE PATTERN, because the phase-order rule and the
|
|
668
|
+
// locked-artifact rule share EVERY write-tool pattern (see `builtInToolPolicyRules`).
|
|
669
|
+
// Selecting by pattern alone would give BOTH rules the locked-artifact condition — the
|
|
670
|
+
// second would then deny a locked target with the wrong reason and the phase-order
|
|
671
|
+
// condition would never be attached at all, so the hole would stay open in every repo
|
|
672
|
+
// that ships a policy file. The `label` is already the rule's machine-readable identity
|
|
673
|
+
// (`firstPolicyDefect` admits it, `decide` surfaces it as the decision's `rule`), so the
|
|
674
|
+
// label is what names the condition. A rule with NO label keeps the pattern's condition,
|
|
675
|
+
// exactly as before.
|
|
676
|
+
if (rule.label === 'phase-order') {
|
|
677
|
+
return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null) }
|
|
678
|
+
}
|
|
526
679
|
if (WRITE_TOOL_NAMES.has(rule.pattern)) {
|
|
527
680
|
return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null) }
|
|
528
681
|
}
|
package/src/policy.ts
CHANGED
|
@@ -111,6 +111,7 @@ export function renderStableContract(config: EnforcementConfig = DEFAULT_ENFORCE
|
|
|
111
111
|
+ ', tamper detection ' + config.tamper + '.',
|
|
112
112
|
'- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition proceeds silently.',
|
|
113
113
|
'- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.',
|
|
114
|
+
'- Phase order binds WRITES as well as locks: only the ACTIVE phase (the lowest-numbered artifact not yet LOCKED) may be written; a write to a LATER phase artifact is denied/asked. Run support files (evidence/, scratch/, addenda/, subagents/, operations/) are not phases.',
|
|
114
115
|
'- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.',
|
|
115
116
|
'- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another).',
|
|
116
117
|
].join('\n')
|
|
@@ -170,6 +170,54 @@ export function buildAskQuestion(gateId: AskGateId): AskQuestion {
|
|
|
170
170
|
})
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* FU-7 — THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE.
|
|
175
|
+
*
|
|
176
|
+
* A blocked lock is refused by TWO layers. The TOOL refuses when `lockArtifact` throws
|
|
177
|
+
* `Prerequisite blockers:`; the GUARD refuses pre-dispatch when the lock-order rule fires
|
|
178
|
+
* (`monotonic lock-order`), and under the strict default that is the layer the caller meets
|
|
179
|
+
* first. Both must hand the caller the SAME choice, so both build it HERE.
|
|
180
|
+
*
|
|
181
|
+
* ⚠ WHY NOT TWO LITERALS. `fix | reopen | abandon` is the human's way out of a blocked lock,
|
|
182
|
+
* and it existed in exactly one call site (`recursive_lock.tool.ts`). The guard's refusal moved
|
|
183
|
+
* to the default path, and a second hand-written copy of the options there would be a second
|
|
184
|
+
* answer to "what can a person do about this?": the day the options change, one of the two
|
|
185
|
+
* refusals keeps offering the old set and nothing fails. One builder, called from both.
|
|
186
|
+
*
|
|
187
|
+
* `blocked` is the refusal's OWN sentence — the guard's rule text or the tool's exception
|
|
188
|
+
* message — carried as data so the payload says what it is about without a reader having to
|
|
189
|
+
* match it against the text beside it.
|
|
190
|
+
*/
|
|
191
|
+
export interface GateBlockAsk extends AskQuestion {
|
|
192
|
+
gate: 'gate-block'
|
|
193
|
+
artifact: string
|
|
194
|
+
blocked: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
|
|
198
|
+
export function buildGateBlockAsk(artifact: string, blocked: string): GateBlockAsk {
|
|
199
|
+
return { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact, blocked }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
|
|
204
|
+
*
|
|
205
|
+
* WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
|
|
206
|
+
* `Error: <reason>` and drops every other field of the decision (measured in
|
|
207
|
+
* `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
|
|
208
|
+
* ask that rode along as a SIBLING field would reach the model as nothing at all — which is
|
|
209
|
+
* exactly how a strict-by-default guard made the recovery path unreachable. The refusal
|
|
210
|
+
* therefore renders the payload into the text it hands back, and it renders THIS object, so
|
|
211
|
+
* the visible sentence and the structured payload cannot disagree.
|
|
212
|
+
*/
|
|
213
|
+
export function renderGateBlockAsk(ask: GateBlockAsk): string {
|
|
214
|
+
const options = ask.options
|
|
215
|
+
.map((option) => option.label + (option.description === undefined ? '' : ' (' + option.description + ')'))
|
|
216
|
+
.join(' ')
|
|
217
|
+
const target = ask.artifact === '' ? 'recursive_ask gate=gate-block' : 'recursive_ask gate=gate-block artifact=' + ask.artifact
|
|
218
|
+
return ask.header + ': ' + ask.question + ' Options: ' + options + ' Answer with ' + target + '.'
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
/**
|
|
174
222
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
175
223
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
-
import {
|
|
3
|
+
import { buildGateBlockAsk } from './recursive_ask.tool.ts'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
6
6
|
|
|
@@ -37,10 +37,16 @@ export function createRecursiveLockTool(recursive: RecursiveRuntime) {
|
|
|
37
37
|
// ⚠ IT IS ATTACHED TO THE ORDERING REFUSAL SPECIFICALLY (`Prerequisite blockers:`), because that
|
|
38
38
|
// is the one a human resolves. A missing run id or an already-locked artifact is a caller mistake
|
|
39
39
|
// with a mechanical fix, and offering "reopen / abandon the run" for those would be noise.
|
|
40
|
+
//
|
|
41
|
+
// ⚠ AND THE PAYLOAD COMES FROM `buildGateBlockAsk`, NOT FROM A LITERAL HERE. The GUARD refuses
|
|
42
|
+
// the same ordering violation pre-dispatch (it resolves the same blockers from the same run
|
|
43
|
+
// tree) and attaches this same payload; one builder is what keeps the two refusals offering the
|
|
44
|
+
// same options. This branch still fires whenever the guard ABSTAINS — most visibly when the call
|
|
45
|
+
// names a run other than the active one, because the guard resolves the run from the filesystem.
|
|
40
46
|
if (message.startsWith('Prerequisite blockers:')) {
|
|
41
47
|
return {
|
|
42
48
|
error: refusal,
|
|
43
|
-
ask:
|
|
49
|
+
ask: buildGateBlockAsk(args.artifact ?? '', message),
|
|
44
50
|
} as unknown as JsonValue
|
|
45
51
|
}
|
|
46
52
|
return { error: refusal } as const
|
package/src/runtime.ts
CHANGED
|
@@ -1881,7 +1881,7 @@ export class RecursiveRuntime extends Service {
|
|
|
1881
1881
|
return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
|
|
1882
1882
|
}
|
|
1883
1883
|
|
|
1884
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
1884
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
1885
1885
|
get enforcementConfig(): EnforcementConfig {
|
|
1886
1886
|
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
|
|
1887
1887
|
}
|