@try-works/dsh-recursive-mode 0.4.8 → 0.4.9

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.
@@ -55,8 +55,14 @@ export declare const DEFAULT_ENFORCEMENT: EnforcementConfig;
55
55
  * every allow/deny/ask the guard hands back). `'none'` means no guard predicate
56
56
  * fired; `'transition'` marks a decision whose only dissenting gate was the
57
57
  * advisory transition gate (see consultTransitionGate).
58
+ *
59
+ * `'phase-order'` is the WRITE half of the ordering rule the owner states as *only
60
+ * one phase may be active at a time, and the phases should be sequential and the
61
+ * active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
62
+ * locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
63
+ * because the guard log has to tell the owner which of the two the agent attempted.
58
64
  */
59
- export type GuardRule = 'lock-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
65
+ export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
60
66
  /** T15: the transition gate's verdict as attached to a decision (advisory only). */
61
67
  export interface GuardTransition {
62
68
  passed: boolean;
@@ -108,7 +114,7 @@ export interface ToolExecLike {
108
114
  * `recursive:policy` later — can read the effective rules instead of inferring
109
115
  * them from a code path.
110
116
  */
111
- export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: string): ToolPolicy;
117
+ export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: string, activePhaseArtifact?: string): ToolPolicy;
112
118
  /**
113
119
  * The artifact whose phase baseline applies: the HIGHEST-numbered phase artifact
114
120
  * present in the run (a run at phase 3 has `00`-`03` on disk). Read from the
package/lib/index.js CHANGED
@@ -1512,11 +1512,91 @@ function lockedWriteRule(target, worktreeRoot) {
1512
1512
  };
1513
1513
  }
1514
1514
  /**
1515
+ * The file name when `abs` is a DIRECT CHILD of `runDir`, and `null` otherwise.
1516
+ *
1517
+ * ⚠ THE DIRECT-CHILD TEST IS NOT TIDINESS — IT IS WHAT KEEPS THE RULE OFF THE SUPPORT
1518
+ * FILES. `phaseNumberForArtifact` reads the leading digits of a NAME, so
1519
+ * `<run>/evidence/01-as-is.md` or `<run>/subagents/child/03-brief.md` would look like
1520
+ * phase 1 and phase 3 artifacts if the name were all that was examined. A phase artifact
1521
+ * is a file the run tree holds DIRECTLY beside the others (`recursive_init` writes all
1522
+ * twelve into `<run>/` itself), so the parent directory is part of the definition.
1523
+ *
1524
+ * The comparison normalizes separators and case: the same run directory reached through
1525
+ * a Windows spelling that differs in case is the same directory, and the rule must not
1526
+ * abstain on one spelling and fire on the other.
1527
+ */
1528
+ function directChildName(abs, runDir) {
1529
+ const normalize = (path) => resolve(path).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1530
+ if (normalize(dirname(abs)) !== normalize(runDir)) return null;
1531
+ return basename(abs);
1532
+ }
1533
+ /**
1534
+ * PHASE-ORDER rule: a denial when the target is a LATER phase's artifact than the phase
1535
+ * currently active, `null` when it is not.
1536
+ *
1537
+ * ⚠ THE HOLE THIS CLOSES. The monotonic rule was enforced on `recursive_lock` ONLY. An
1538
+ * agent could therefore write `08-memory-impact.md` while the run sat at phase 0 — and a
1539
+ * live run did exactly that: twelve artifacts, not one of them locked, written out of
1540
+ * order, with a single line in `operations/operations.jsonl`. Ordering that only binds
1541
+ * the lock tool is not ordering; the model's ordinary `write` is the path that mattered.
1542
+ *
1543
+ * The owner's rule, verbatim: *only one phase may be active at a time, and the phases
1544
+ * should be sequential and the active phase must be locked before proceeding to next
1545
+ * phase*. The ACTIVE phase is the one the selector names (`ctx.activePhaseArtifact`), so:
1546
+ *
1547
+ * - the target is the ACTIVE artifact, or shares its phase number (`00-requirements.md`
1548
+ * and `00-worktree.md` are both phase 0; `01-as-is.md` and `01.5-root-cause.md` are
1549
+ * both phase 1) -> ABSTAIN, the write is allowed;
1550
+ * - the target is an EARLIER phase -> ABSTAIN. Such an artifact is LOCKED by
1551
+ * construction (the active phase is the lowest UNLOCKED one), so the locked-artifact
1552
+ * rule above decides it, and its rule label is preserved;
1553
+ * - the target is a LATER phase -> DENY: working ahead.
1554
+ *
1555
+ * ⚠ THE ALLOW HALF IS LOAD-BEARING. An enforcement rule in this exact area was once the
1556
+ * bug: strict enforcement denied the run's OWN artifacts in every phase and made the
1557
+ * workflow unusable (see `resolveFrom` and `currentPhaseArtifact`). "The active artifact
1558
+ * stays writable at every phase" is therefore asserted by walking every phase, not by
1559
+ * one case — `tests/strict-run-tree.spec.ts` (d).
1560
+ *
1561
+ * WHAT IT ABSTAINS ON, deliberately:
1562
+ * - no `activePhaseArtifact` (a caller with no run context, or a run with no phase
1563
+ * artifacts yet) -> abstain, never guess a phase;
1564
+ * - a target outside the active run's own directory -> abstain. The rule is about THIS
1565
+ * run's sequence; another run's tree is a different question and denying it here
1566
+ * would be a false positive;
1567
+ * - a support file (anything not a direct child) -> abstain: `evidence/`, `scratch/`,
1568
+ * `addenda/`, `subagents/`, `operations/` and a plain `<run>/notes.md` are not phases.
1569
+ *
1570
+ * The predicate adds only the per-call particular — which artifact, which phase, which is
1571
+ * active; the rule keeps the static, auditable sentence.
1572
+ */
1573
+ function phaseOrderRule(target, ctx) {
1574
+ const active = ctx.activePhaseArtifact;
1575
+ if (!target || !ctx.runDir || !ctx.worktreeRoot) return null;
1576
+ if (typeof active !== "string" || active === "") return null;
1577
+ const activePhaseText = phaseNumberForArtifact(active);
1578
+ if (!activePhaseText) return null;
1579
+ const normalized = target.replace(/\\/g, "/");
1580
+ if (!normalized.endsWith(".md")) return null;
1581
+ const abs = resolveFrom(ctx.worktreeRoot, normalized);
1582
+ if (!abs) return null;
1583
+ const name = directChildName(abs, ctx.runDir);
1584
+ if (name === null) return null;
1585
+ const phaseText = phaseNumberForArtifact(name);
1586
+ if (!phaseText) return null;
1587
+ if (Number(phaseText) <= Number(activePhaseText)) return null;
1588
+ return {
1589
+ verdict: "deny",
1590
+ detail: name + " is phase " + phaseText + " but the ACTIVE phase is " + active + " (phase " + activePhaseText + ") - the active phase must be locked before writing a later phase"
1591
+ };
1592
+ }
1593
+ /**
1515
1594
  * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
1516
1595
  * data:
1517
1596
  *
1518
1597
  * `recursive_lock*` -> the monotonic lock-order denial;
1519
1598
  * the write-tool ids -> the locked-artifact write denial;
1599
+ * the write-tool ids -> the phase-order (write-ahead) denial;
1520
1600
  * `*` -> allow, so an ordinary tool is not turned into an `ask`.
1521
1601
  *
1522
1602
  * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
@@ -1543,6 +1623,13 @@ function builtInToolPolicyRules() {
1543
1623
  label: "locked-write",
1544
1624
  predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null
1545
1625
  });
1626
+ for (const name of WRITE_TOOL_NAMES) rules.push({
1627
+ pattern: name,
1628
+ verdict: "deny",
1629
+ 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",
1630
+ label: "phase-order",
1631
+ predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
1632
+ });
1546
1633
  rules.push({
1547
1634
  pattern: "*",
1548
1635
  verdict: "allow",
@@ -1581,6 +1668,10 @@ function attachPolicyPredicate(rule) {
1581
1668
  ...rule,
1582
1669
  predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null
1583
1670
  };
1671
+ if (rule.label === "phase-order") return {
1672
+ ...rule,
1673
+ predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
1674
+ };
1584
1675
  if (WRITE_TOOL_NAMES.has(rule.pattern)) return {
1585
1676
  ...rule,
1586
1677
  predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null
@@ -7784,8 +7875,8 @@ const DEFAULT_ENFORCEMENT = {
7784
7875
  * `recursive:policy` later — can read the effective rules instead of inferring
7785
7876
  * them from a code path.
7786
7877
  */
7787
- function resolveToolPolicyForGuard(worktreeRoot, runId) {
7788
- return withPhaseBaseline(loadToolPolicyFile(worktreeRoot).policy, currentPhaseArtifact(worktreeRoot, runId));
7878
+ function resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact) {
7879
+ return withPhaseBaseline(loadToolPolicyFile(worktreeRoot).policy, activePhaseArtifact ?? currentPhaseArtifact(worktreeRoot, runId));
7789
7880
  }
7790
7881
  /**
7791
7882
  * The artifact whose phase baseline applies: the HIGHEST-numbered phase artifact
@@ -7835,11 +7926,13 @@ function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = "advisory") {
7835
7926
  const runId = typeof activeRunId === "string" ? activeRunId.trim() : "";
7836
7927
  const runDir = join(worktreeRoot, ".recursive", "run", runId);
7837
7928
  const transition = consultTransitionGate(name, args, worktreeRoot, runId);
7838
- return advisory(verdictFor(mode, evaluateToolPolicy(resolveToolPolicyForGuard(worktreeRoot, runId), name, args, {
7929
+ const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId);
7930
+ return advisory(verdictFor(mode, evaluateToolPolicy(resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact), name, args, {
7839
7931
  args,
7840
7932
  runDir,
7841
7933
  runId,
7842
- worktreeRoot
7934
+ worktreeRoot,
7935
+ activePhaseArtifact
7843
7936
  })), transition);
7844
7937
  }
7845
7938
  /**
@@ -8086,6 +8179,7 @@ function renderStableContract(config = DEFAULT_ENFORCEMENT) {
8086
8179
  "- Gates in force: pre-step " + config.preStep + ", tool guards " + config.toolGuards + ", tamper detection " + config.tamper + ".",
8087
8180
  "- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition proceeds silently.",
8088
8181
  "- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.",
8182
+ "- 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.",
8089
8183
  "- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.",
8090
8184
  "- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another)."
8091
8185
  ].join("\n");
@@ -21,6 +21,18 @@ export interface ToolPolicyContext {
21
21
  runDir?: string;
22
22
  runId?: string;
23
23
  worktreeRoot?: string;
24
+ /**
25
+ * The ACTIVE phase artifact of the run — the LOWEST-numbered phase artifact that is not
26
+ * LOCKED, falling back to the highest when every one of them is locked.
27
+ *
28
+ * ⚠ THIS IS NOT A SECOND SELECTOR. It is the answer `currentPhaseArtifact` in
29
+ * `enforcement.ts` produced for this call, carried here so a rule in THIS module can
30
+ * compare against it. `policy-globs.ts` cannot import that function (enforcement.ts
31
+ * imports this module, and the repo has already paid once for a value-level import
32
+ * cycle), so the value is passed in rather than recomputed. A caller that omits it
33
+ * gets NO phase-order verdict — the rule abstains rather than guessing a phase.
34
+ */
35
+ activePhaseArtifact?: string;
24
36
  }
25
37
  /**
26
38
  * What a rule predicate returns when the condition it guards DOES apply:
@@ -152,6 +164,7 @@ export declare const LOCK_TOOL_NAMES: Set<string>;
152
164
  *
153
165
  * `recursive_lock*` -> the monotonic lock-order denial;
154
166
  * the write-tool ids -> the locked-artifact write denial;
167
+ * the write-tool ids -> the phase-order (write-ahead) denial;
155
168
  * `*` -> allow, so an ordinary tool is not turned into an `ask`.
156
169
  *
157
170
  * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
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.4.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -129,8 +129,14 @@ export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
129
129
  * every allow/deny/ask the guard hands back). `'none'` means no guard predicate
130
130
  * fired; `'transition'` marks a decision whose only dissenting gate was the
131
131
  * advisory transition gate (see consultTransitionGate).
132
+ *
133
+ * `'phase-order'` is the WRITE half of the ordering rule the owner states as *only
134
+ * one phase may be active at a time, and the phases should be sequential and the
135
+ * active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
136
+ * locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
137
+ * because the guard log has to tell the owner which of the two the agent attempted.
132
138
  */
133
- export type GuardRule = 'lock-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
139
+ export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
134
140
 
135
141
  /** T15: the transition gate's verdict as attached to a decision (advisory only). */
136
142
  export interface GuardTransition {
@@ -179,9 +185,12 @@ export interface ToolExecLike {
179
185
  * `recursive:policy` later — can read the effective rules instead of inferring
180
186
  * them from a code path.
181
187
  */
182
- export function resolveToolPolicyForGuard(worktreeRoot: string, runId: string): ToolPolicy {
188
+ export function resolveToolPolicyForGuard(worktreeRoot: string, runId: string, activePhaseArtifact?: string): ToolPolicy {
183
189
  const loaded = loadToolPolicyFile(worktreeRoot)
184
- return withPhaseBaseline(loaded.policy, currentPhaseArtifact(worktreeRoot, runId))
190
+ // A caller that already asked `currentPhaseArtifact` for this call passes the answer in,
191
+ // so one guard call reads the run tree's lock statuses ONCE rather than twice. The
192
+ // no-cache discipline is unchanged: an omitted argument still reads the filesystem here.
193
+ return withPhaseBaseline(loaded.policy, activePhaseArtifact ?? currentPhaseArtifact(worktreeRoot, runId))
185
194
  }
186
195
 
187
196
  /**
@@ -258,8 +267,14 @@ export function evaluateToolGuard(
258
267
  // T16: the verdict comes from the ordered policy, not from branches here. The
259
268
  // policy file is re-read per call on purpose: a policy a human just edited
260
269
  // 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 }
270
+ //
271
+ // The ACTIVE phase is resolved ONCE, by the ONE selector, and is used twice: it
272
+ // selects the phase baseline (narrowing rules) and it is carried into the context so
273
+ // the phase-order rule can refuse a WRITE that is ahead of the active phase. Both
274
+ // halves of the ordering rule therefore read the same answer for the same call.
275
+ const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId)
276
+ const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
277
+ const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
263
278
  const decision = evaluateToolPolicy(policy, name, args, context)
264
279
  return advisory(verdictFor(mode, decision), transition)
265
280
  }
@@ -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'
43
+ import { basename, dirname, join, resolve } from 'node:path'
44
44
  import { getLockStatus, getPrerequisiteBlockers } 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'
@@ -70,6 +70,18 @@ export interface ToolPolicyContext {
70
70
  runDir?: string
71
71
  runId?: string
72
72
  worktreeRoot?: string
73
+ /**
74
+ * The ACTIVE phase artifact of the run — the LOWEST-numbered phase artifact that is not
75
+ * LOCKED, falling back to the highest when every one of them is locked.
76
+ *
77
+ * ⚠ THIS IS NOT A SECOND SELECTOR. It is the answer `currentPhaseArtifact` in
78
+ * `enforcement.ts` produced for this call, carried here so a rule in THIS module can
79
+ * compare against it. `policy-globs.ts` cannot import that function (enforcement.ts
80
+ * imports this module, and the repo has already paid once for a value-level import
81
+ * cycle), so the value is passed in rather than recomputed. A caller that omits it
82
+ * gets NO phase-order verdict — the rule abstains rather than guessing a phase.
83
+ */
84
+ activePhaseArtifact?: string
73
85
  }
74
86
 
75
87
  /**
@@ -452,12 +464,95 @@ function lockedWriteRule(target: string | null, worktreeRoot: string | undefined
452
464
  return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
453
465
  }
454
466
 
467
+ /**
468
+ * The file name when `abs` is a DIRECT CHILD of `runDir`, and `null` otherwise.
469
+ *
470
+ * ⚠ THE DIRECT-CHILD TEST IS NOT TIDINESS — IT IS WHAT KEEPS THE RULE OFF THE SUPPORT
471
+ * FILES. `phaseNumberForArtifact` reads the leading digits of a NAME, so
472
+ * `<run>/evidence/01-as-is.md` or `<run>/subagents/child/03-brief.md` would look like
473
+ * phase 1 and phase 3 artifacts if the name were all that was examined. A phase artifact
474
+ * is a file the run tree holds DIRECTLY beside the others (`recursive_init` writes all
475
+ * twelve into `<run>/` itself), so the parent directory is part of the definition.
476
+ *
477
+ * The comparison normalizes separators and case: the same run directory reached through
478
+ * a Windows spelling that differs in case is the same directory, and the rule must not
479
+ * abstain on one spelling and fire on the other.
480
+ */
481
+ function directChildName(abs: string, runDir: string): string | null {
482
+ const normalize = (path: string) => resolve(path).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
483
+ if (normalize(dirname(abs)) !== normalize(runDir)) return null
484
+ return basename(abs)
485
+ }
486
+
487
+ /**
488
+ * PHASE-ORDER rule: a denial when the target is a LATER phase's artifact than the phase
489
+ * currently active, `null` when it is not.
490
+ *
491
+ * ⚠ THE HOLE THIS CLOSES. The monotonic rule was enforced on `recursive_lock` ONLY. An
492
+ * agent could therefore write `08-memory-impact.md` while the run sat at phase 0 — and a
493
+ * live run did exactly that: twelve artifacts, not one of them locked, written out of
494
+ * order, with a single line in `operations/operations.jsonl`. Ordering that only binds
495
+ * the lock tool is not ordering; the model's ordinary `write` is the path that mattered.
496
+ *
497
+ * The owner's rule, verbatim: *only one phase may be active at a time, and the phases
498
+ * should be sequential and the active phase must be locked before proceeding to next
499
+ * phase*. The ACTIVE phase is the one the selector names (`ctx.activePhaseArtifact`), so:
500
+ *
501
+ * - the target is the ACTIVE artifact, or shares its phase number (`00-requirements.md`
502
+ * and `00-worktree.md` are both phase 0; `01-as-is.md` and `01.5-root-cause.md` are
503
+ * both phase 1) -> ABSTAIN, the write is allowed;
504
+ * - the target is an EARLIER phase -> ABSTAIN. Such an artifact is LOCKED by
505
+ * construction (the active phase is the lowest UNLOCKED one), so the locked-artifact
506
+ * rule above decides it, and its rule label is preserved;
507
+ * - the target is a LATER phase -> DENY: working ahead.
508
+ *
509
+ * ⚠ THE ALLOW HALF IS LOAD-BEARING. An enforcement rule in this exact area was once the
510
+ * bug: strict enforcement denied the run's OWN artifacts in every phase and made the
511
+ * workflow unusable (see `resolveFrom` and `currentPhaseArtifact`). "The active artifact
512
+ * stays writable at every phase" is therefore asserted by walking every phase, not by
513
+ * one case — `tests/strict-run-tree.spec.ts` (d).
514
+ *
515
+ * WHAT IT ABSTAINS ON, deliberately:
516
+ * - no `activePhaseArtifact` (a caller with no run context, or a run with no phase
517
+ * artifacts yet) -> abstain, never guess a phase;
518
+ * - a target outside the active run's own directory -> abstain. The rule is about THIS
519
+ * run's sequence; another run's tree is a different question and denying it here
520
+ * would be a false positive;
521
+ * - a support file (anything not a direct child) -> abstain: `evidence/`, `scratch/`,
522
+ * `addenda/`, `subagents/`, `operations/` and a plain `<run>/notes.md` are not phases.
523
+ *
524
+ * The predicate adds only the per-call particular — which artifact, which phase, which is
525
+ * active; the rule keeps the static, auditable sentence.
526
+ */
527
+ function phaseOrderRule(target: string | null, ctx: ToolPolicyContext): ToolPolicyPredicateMatch | null {
528
+ const active = ctx.activePhaseArtifact
529
+ if (!target || !ctx.runDir || !ctx.worktreeRoot) return null
530
+ if (typeof active !== 'string' || active === '') return null
531
+ const activePhaseText = phaseNumberForArtifact(active)
532
+ if (!activePhaseText) return null
533
+ const normalized = target.replace(/\\/g, '/')
534
+ if (!normalized.endsWith('.md')) return null
535
+ const abs = resolveFrom(ctx.worktreeRoot, normalized)
536
+ if (!abs) return null
537
+ const name = directChildName(abs, ctx.runDir)
538
+ if (name === null) return null
539
+ const phaseText = phaseNumberForArtifact(name)
540
+ if (!phaseText) return null
541
+ if (Number(phaseText) <= Number(activePhaseText)) return null
542
+ return {
543
+ verdict: 'deny',
544
+ detail: name + ' is phase ' + phaseText + ' but the ACTIVE phase is ' + active
545
+ + ' (phase ' + activePhaseText + ') - the active phase must be locked before writing a later phase',
546
+ }
547
+ }
548
+
455
549
  /**
456
550
  * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
457
551
  * data:
458
552
  *
459
553
  * `recursive_lock*` -> the monotonic lock-order denial;
460
554
  * the write-tool ids -> the locked-artifact write denial;
555
+ * the write-tool ids -> the phase-order (write-ahead) denial;
461
556
  * `*` -> allow, so an ordinary tool is not turned into an `ask`.
462
557
  *
463
558
  * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
@@ -488,6 +583,20 @@ export function builtInToolPolicyRules(): ToolPolicyRule[] {
488
583
  predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null),
489
584
  })
490
585
  }
586
+ // The SAME write-tool ids get a SECOND conditional deny, the phase-order rule. Two rules
587
+ // share one pattern on purpose: the engine's predicates ABSTAIN (`null`) when their
588
+ // condition does not apply, so a clean write falls through both to the catch-all allow,
589
+ // and a target that is BOTH locked and a later phase is reported by the locked rule
590
+ // first (file order within a specificity tier), which is the pre-existing wording.
591
+ for (const name of WRITE_TOOL_NAMES) {
592
+ rules.push({
593
+ pattern: name,
594
+ verdict: 'deny',
595
+ 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',
596
+ label: 'phase-order',
597
+ predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null),
598
+ })
599
+ }
491
600
  rules.push({
492
601
  pattern: '*',
493
602
  verdict: 'allow',
@@ -523,6 +632,18 @@ export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
523
632
  if (rule.pattern === 'recursive_lock*') {
524
633
  return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null) }
525
634
  }
635
+ // ⚠ THE LABEL IS CONSULTED BEFORE THE PATTERN, because the phase-order rule and the
636
+ // locked-artifact rule share EVERY write-tool pattern (see `builtInToolPolicyRules`).
637
+ // Selecting by pattern alone would give BOTH rules the locked-artifact condition — the
638
+ // second would then deny a locked target with the wrong reason and the phase-order
639
+ // condition would never be attached at all, so the hole would stay open in every repo
640
+ // that ships a policy file. The `label` is already the rule's machine-readable identity
641
+ // (`firstPolicyDefect` admits it, `decide` surfaces it as the decision's `rule`), so the
642
+ // label is what names the condition. A rule with NO label keeps the pattern's condition,
643
+ // exactly as before.
644
+ if (rule.label === 'phase-order') {
645
+ return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null) }
646
+ }
526
647
  if (WRITE_TOOL_NAMES.has(rule.pattern)) {
527
648
  return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null) }
528
649
  }
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')