@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.
@@ -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
- /** One place where a rule's verdict becomes a decision, so `label` cannot drift. */
339
- function decide(rule: ToolPolicyRule, kind: Verdict, reason: string): Decision {
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 { verdict: 'deny', detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') }
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 { buildAskQuestion } from './recursive_ask.tool.ts'
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: { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact: args.artifact ?? '', blocked: message },
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 advisory). */
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
  }