@danmoisan/drm-copilot-mcp 1.1.0 → 1.1.2

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.
Files changed (45) hide show
  1. package/out/mcp-server.js +10 -2
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
  4. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +118 -6
  5. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +34 -60
  6. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +187 -0
  7. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +16 -0
  8. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +62 -4
  9. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +27 -0
  10. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +28 -0
  11. package/resources/claude-customizations/config/orchestration-routing.json +1 -1
  12. package/resources/claude-customizations/pack-manifests/core.json +2 -0
  13. package/resources/codex-and-agents-customizations/.agents/skills/architecture-boundaries/SKILL.md +0 -3
  14. package/resources/codex-and-agents-customizations/.agents/skills/csharp/SKILL.md +0 -3
  15. package/resources/codex-and-agents-customizations/.agents/skills/csharp-change-budget-router/SKILL.md +1 -1
  16. package/resources/codex-and-agents-customizations/.agents/skills/csharp-qa-gate/SKILL.md +1 -1
  17. package/resources/codex-and-agents-customizations/.agents/skills/epic-plan/SKILL.md +1 -1
  18. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +2 -1
  19. package/resources/codex-and-agents-customizations/.agents/skills/general-code-change/SKILL.md +0 -2
  20. package/resources/codex-and-agents-customizations/.agents/skills/general-unit-test/SKILL.md +0 -2
  21. package/resources/codex-and-agents-customizations/.agents/skills/invoke-csharp-engineer/SKILL.md +1 -1
  22. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -1
  23. package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +1 -1
  24. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +3 -2
  25. package/resources/codex-and-agents-customizations/.agents/skills/policy-audit-template-usage/SKILL.md +1 -1
  26. package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +0 -4
  27. package/resources/codex-and-agents-customizations/.agents/skills/powershell-change-budget-router/SKILL.md +1 -1
  28. package/resources/codex-and-agents-customizations/.agents/skills/powershell-qa-gate/SKILL.md +1 -1
  29. package/resources/codex-and-agents-customizations/.agents/skills/python/SKILL.md +0 -2
  30. package/resources/codex-and-agents-customizations/.agents/skills/python-qa-gate/SKILL.md +1 -1
  31. package/resources/codex-and-agents-customizations/.agents/skills/python-suppressions/SKILL.md +0 -2
  32. package/resources/codex-and-agents-customizations/.agents/skills/quality-tiers/SKILL.md +0 -2
  33. package/resources/codex-and-agents-customizations/.agents/skills/research-issue/SKILL.md +4 -3
  34. package/resources/codex-and-agents-customizations/.agents/skills/self-explanatory-code-commenting/SKILL.md +0 -2
  35. package/resources/codex-and-agents-customizations/.agents/skills/tonality/SKILL.md +0 -2
  36. package/resources/codex-and-agents-customizations/.agents/skills/translate-claude-to-codex/SKILL.md +3 -3
  37. package/resources/codex-and-agents-customizations/.agents/skills/translate-copilot-to-claude/SKILL.md +1 -1
  38. package/resources/codex-and-agents-customizations/.agents/skills/typescript/SKILL.md +0 -2
  39. package/resources/codex-and-agents-customizations/.agents/skills/typescript-suppressions/SKILL.md +0 -2
  40. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  41. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
  42. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +123 -6
  43. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  44. package/resources/config/orchestration-routing.json +1 -1
  45. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +17 -1
package/out/mcp-server.js CHANGED
@@ -23470,6 +23470,9 @@ function validateModelReceipt(feature, prefix, delegation, expectedExecutionCont
23470
23470
  }
23471
23471
  return errors;
23472
23472
  }
23473
+ function featureCarriesLaunchPath(feature) {
23474
+ return "launch_receipt_path" in feature || "launch_status_path" in feature;
23475
+ }
23473
23476
  function validateLaunchBindings(features, context) {
23474
23477
  const errors = [];
23475
23478
  const seenBranches = /* @__PURE__ */ new Set();
@@ -23481,6 +23484,9 @@ function validateLaunchBindings(features, context) {
23481
23484
  if (context.skipNotStarted && item["merge_status"] === "not_started") {
23482
23485
  return;
23483
23486
  }
23487
+ if (context.requireLaunchPaths && !featureCarriesLaunchPath(item)) {
23488
+ return;
23489
+ }
23484
23490
  const prefix = context.planner ? `Epic planner checkpoint features[${index}] launch binding` : featurePrefix(item);
23485
23491
  errors.push(...validateBranchAndPaths(item, prefix, seenBranches));
23486
23492
  const delegation = validateDelegationReceipt(
@@ -23506,7 +23512,8 @@ function validateEpicPlannerChildLaunchBindings(features) {
23506
23512
  expectedExecutionContext: "epic_preparation_child",
23507
23513
  planner: true,
23508
23514
  requireGeneratedOrchestrator: true,
23509
- skipNotStarted: false
23515
+ skipNotStarted: false,
23516
+ requireLaunchPaths: false
23510
23517
  });
23511
23518
  }
23512
23519
  function validateEpicChildLaunchBindings(state, options = {}) {
@@ -23521,7 +23528,8 @@ function validateEpicChildLaunchBindings(state, options = {}) {
23521
23528
  expectedExecutionContext: "epic_execution_child",
23522
23529
  planner: false,
23523
23530
  requireGeneratedOrchestrator: false,
23524
- skipNotStarted: options.requireComplete !== true
23531
+ skipNotStarted: options.requireComplete !== true,
23532
+ requireLaunchPaths: options.requireCodexModelRouting !== true && options.requireCodexTopology !== true
23525
23533
  });
23526
23534
  }
23527
23535
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -0,0 +1,349 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pathspec classifier for the orchestration-bookkeeping staging exemption (issue #539).
4
+ .DESCRIPTION
5
+ Pure string logic only: no disk, process, network, or environment access. The single
6
+ entry predicate `Test-ExemptOrchestrationStagingCommand` answers one question - does
7
+ this command text parse, in its entirety, as recognized staging or integration
8
+ invocations whose every pathspec operand resolves inside an orchestration-bookkeeping
9
+ tree? Every parse ambiguity answers false, so the caller's pre-change deny is the
10
+ fallback for every unmodeled form.
11
+
12
+ The normative contract is the D4 fail-closed rule table in
13
+ docs/features/active/2026-08-24-preimplementation-gate-blocks-planner-integration-commits-539/spec.md.
14
+ Rule-table row numbers are cited inline against the code that realizes them.
15
+
16
+ This file is dot-sourced by the sibling gate hook, following the headroom-split
17
+ precedent set by enforce-pr-author-skill.ps1.
18
+ #>
19
+
20
+ # The five exempt orchestration-bookkeeping trees (D2). Directory prefixes, repo-relative,
21
+ # forward-slash spelled. No glob, no absolute entry, no literal-file entry.
22
+ $script:OrchestrationBookkeepingTrees = @(
23
+ 'docs/features/epics/'
24
+ 'docs/features/parallel/'
25
+ 'docs/features/active/'
26
+ 'docs/features/potential/'
27
+ 'artifacts/orchestration/'
28
+ )
29
+
30
+ # Characters that make a command line statically unresolvable: shell interpolation and
31
+ # redirection (D4 row 12). Tested across the whole line rather than per operand, because a
32
+ # redirection anywhere in the line moves content the operand list cannot describe.
33
+ $script:UnresolvableCommandCharacters = [char[]]@('$', '`', '>', '<')
34
+
35
+ # Wildcards that make an operand a glob (D4 row 15). Only the literal prefix before the
36
+ # first of these is prefix-tested.
37
+ $script:PathspecWildcardCharacters = [char[]]@('*', '?', '[')
38
+
39
+ function Split-OrchestrationCommandLine {
40
+ <#
41
+ .SYNOPSIS
42
+ Splits a command line into segments on chain operators outside quotes.
43
+ .DESCRIPTION
44
+ Realizes D4 row 13. Quote state is tracked while scanning so a chain operator
45
+ inside a quoted span does not split. The returned `Balanced` flag reports whether
46
+ the scan ended outside every quote; unbalanced text is not splittable and the
47
+ caller denies (D4 rows 11 and 13). Empty and whitespace-only segments are dropped.
48
+ .OUTPUTS
49
+ System.Collections.Hashtable with keys `Balanced` (bool) and `Segments` (string[]).
50
+ #>
51
+ [CmdletBinding()]
52
+ [OutputType([hashtable])]
53
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
54
+
55
+ $segments = [System.Collections.Generic.List[string]]::new()
56
+ $current = [System.Text.StringBuilder]::new()
57
+ $openQuote = [char]0
58
+
59
+ foreach ($character in $CommandText.ToCharArray()) {
60
+ if ($openQuote -ne [char]0) {
61
+ if ($character -eq $openQuote) {
62
+ $openQuote = [char]0
63
+ }
64
+ [void]$current.Append($character)
65
+ continue
66
+ }
67
+
68
+ if ($character -eq '"' -or $character -eq "'") {
69
+ $openQuote = $character
70
+ [void]$current.Append($character)
71
+ continue
72
+ }
73
+
74
+ if ($character -eq ';' -or $character -eq '&' -or $character -eq '|' -or
75
+ $character -eq "`n" -or $character -eq "`r") {
76
+ $segments.Add($current.ToString())
77
+ [void]$current.Clear()
78
+ continue
79
+ }
80
+
81
+ [void]$current.Append($character)
82
+ }
83
+ $segments.Add($current.ToString())
84
+
85
+ return @{
86
+ Balanced = ($openQuote -eq [char]0)
87
+ Segments = @($segments | Where-Object { $_.Trim() })
88
+ }
89
+ }
90
+
91
+ function ConvertTo-OrchestrationCommandToken {
92
+ <#
93
+ .SYNOPSIS
94
+ Splits one segment into whitespace-delimited tokens with balanced quotes stripped.
95
+ .DESCRIPTION
96
+ Realizes the quote handling of D4 row 11. A quoted span contributes to the token it
97
+ sits in, so `-m "epic scaffold"` yields the two tokens `-m` and `epic scaffold`, and
98
+ a quoted operand arrives at the prefix test unquoted. The caller has already
99
+ rejected unbalanced text, so an unterminated quote simply ends the final token.
100
+ .OUTPUTS
101
+ System.String[]
102
+ #>
103
+ [CmdletBinding()]
104
+ [OutputType([string[]])]
105
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $Segment)
106
+
107
+ $tokens = [System.Collections.Generic.List[string]]::new()
108
+ $current = [System.Text.StringBuilder]::new()
109
+ $hasToken = $false
110
+ $openQuote = [char]0
111
+
112
+ foreach ($character in $Segment.ToCharArray()) {
113
+ if ($openQuote -ne [char]0) {
114
+ if ($character -eq $openQuote) {
115
+ $openQuote = [char]0
116
+ } else {
117
+ [void]$current.Append($character)
118
+ }
119
+ continue
120
+ }
121
+
122
+ if ($character -eq '"' -or $character -eq "'") {
123
+ $openQuote = $character
124
+ $hasToken = $true
125
+ continue
126
+ }
127
+
128
+ if ([char]::IsWhiteSpace($character)) {
129
+ if ($hasToken) {
130
+ $tokens.Add($current.ToString())
131
+ [void]$current.Clear()
132
+ $hasToken = $false
133
+ }
134
+ continue
135
+ }
136
+
137
+ $hasToken = $true
138
+ [void]$current.Append($character)
139
+ }
140
+
141
+ if ($hasToken) {
142
+ $tokens.Add($current.ToString())
143
+ }
144
+
145
+ return $tokens.ToArray()
146
+ }
147
+
148
+ function Test-ExemptOrchestrationOperand {
149
+ <#
150
+ .SYNOPSIS
151
+ Tests one pathspec operand against the five exempt orchestration trees.
152
+ .DESCRIPTION
153
+ Realizes D4 rows 3, 9, 15, 16, 17, and 18. Backslashes normalize to forward slashes
154
+ before the prefix test (row 18); pathspec magic, absolute spellings, parent-directory
155
+ segments, and globs whose literal prefix escapes the exempt set all deny.
156
+ .OUTPUTS
157
+ System.Boolean
158
+ #>
159
+ [CmdletBinding()]
160
+ [OutputType([bool])]
161
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $Operand)
162
+
163
+ if (-not $Operand) {
164
+ return $false
165
+ }
166
+
167
+ # Row 3b and row 9: any leading colon is pathspec magic, which can escape or invert the
168
+ # tree scope, so no colon-led operand is ever resolvable by a prefix test.
169
+ if ($Operand.StartsWith(':')) {
170
+ return $false
171
+ }
172
+
173
+ # Row 18: separator normalization precedes every prefix comparison below.
174
+ $normalized = $Operand -replace '\\', '/'
175
+
176
+ # Row 16: rooted, drive-lettered, and UNC spellings all deny; the exempt prefixes stay
177
+ # repo-relative (this is the posture issue #516 later composes with).
178
+ if ($normalized.StartsWith('/')) {
179
+ return $false
180
+ }
181
+ if ($normalized -match '^[A-Za-z]:') {
182
+ return $false
183
+ }
184
+
185
+ # Rows 15c and 17: any parent-directory component escapes the prefix.
186
+ if (($normalized -split '/') -contains '..') {
187
+ return $false
188
+ }
189
+
190
+ # Row 15: only the literal prefix before the first wildcard is prefix-tested, so a glob
191
+ # whose wildcard occupies or truncates an ancestor segment cannot pass.
192
+ $literalPrefix = $normalized
193
+ $wildcardIndex = $normalized.IndexOfAny($script:PathspecWildcardCharacters)
194
+ if ($wildcardIndex -ge 0) {
195
+ $literalPrefix = $normalized.Substring(0, $wildcardIndex)
196
+ }
197
+
198
+ foreach ($tree in $script:OrchestrationBookkeepingTrees) {
199
+ if ($literalPrefix.StartsWith($tree)) {
200
+ return $true
201
+ }
202
+ }
203
+ return $false
204
+ }
205
+
206
+ function Test-ExemptOrchestrationSegmentToken {
207
+ <#
208
+ .SYNOPSIS
209
+ Tests one already-tokenized segment as a recognized all-exempt invocation.
210
+ .DESCRIPTION
211
+ Realizes D4 rows 1, 2, 4, 5, 6, 7, 8, 10, 14, and 19. The command name must lead the
212
+ segment and the subcommand must follow it immediately (row 14), the option table is
213
+ modelled positively so any unmodelled dash-leading token denies (rows 2, 5, 6, 8, 10),
214
+ tokens after the double-dash separator are pathspecs (row 7), at least one operand is
215
+ required (rows 1, 4, 7), and every operand must pass (row 19).
216
+ .OUTPUTS
217
+ System.Boolean
218
+ #>
219
+ [CmdletBinding()]
220
+ [OutputType([bool])]
221
+ param([Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token)
222
+
223
+ if ($Token.Count -lt 2) {
224
+ return $false
225
+ }
226
+
227
+ # Row 14: the command name leads and the subcommand follows immediately. Anything in
228
+ # between - a relocating option or an env-style prefix - moves the pathspec base and is
229
+ # rejected here rather than modelled.
230
+ if ($Token[0] -cne 'git') {
231
+ return $false
232
+ }
233
+ $subcommand = $Token[1]
234
+ if ($subcommand -cne 'add' -and $subcommand -cne 'commit') {
235
+ return $false
236
+ }
237
+
238
+ $operands = [System.Collections.Generic.List[string]]::new()
239
+ $afterSeparator = $false
240
+ $index = 2
241
+
242
+ while ($index -lt $Token.Count) {
243
+ $candidate = $Token[$index]
244
+
245
+ if (-not $afterSeparator) {
246
+ if ($candidate -ceq '--') {
247
+ # Row 7: every remaining token is a pathspec, dash-leading or not.
248
+ $afterSeparator = $true
249
+ $index++
250
+ continue
251
+ }
252
+
253
+ if ($candidate.StartsWith('-')) {
254
+ # The message option is the only modelled option on either subcommand. Rows
255
+ # 2, 5, 6, 8, and 10 all land here and deny, including a dash-leading
256
+ # operand supplied without a preceding separator.
257
+ if ($subcommand -cne 'commit') {
258
+ return $false
259
+ }
260
+ if ($candidate -ceq '-m' -or $candidate -ceq '--message') {
261
+ # The message value is the following token and is not a pathspec.
262
+ $index += 2
263
+ if ($index -gt $Token.Count) {
264
+ return $false
265
+ }
266
+ continue
267
+ }
268
+ if ($candidate.StartsWith('--message=') -or
269
+ ($candidate.Length -gt 2 -and $candidate.StartsWith('-m'))) {
270
+ $index++
271
+ continue
272
+ }
273
+ return $false
274
+ }
275
+ }
276
+
277
+ $operands.Add($candidate)
278
+ $index++
279
+ }
280
+
281
+ # Rows 1, 4, and 7: an invocation with no pathspec operand claims no path, so there is
282
+ # nothing for the prefix test to scope and the exemption is not available.
283
+ if ($operands.Count -eq 0) {
284
+ return $false
285
+ }
286
+
287
+ # Row 19: all-operands-exempt is the invariant; one non-exempt operand denies the set.
288
+ foreach ($operand in $operands) {
289
+ if (-not (Test-ExemptOrchestrationOperand -Operand $operand)) {
290
+ return $false
291
+ }
292
+ }
293
+ return $true
294
+ }
295
+
296
+ function Test-ExemptOrchestrationStagingCommand {
297
+ <#
298
+ .SYNOPSIS
299
+ Reports whether a command line is an orchestration-bookkeeping staging invocation.
300
+ .DESCRIPTION
301
+ The entry predicate of the issue #539 exemption. Returns true only when the whole
302
+ command line splits cleanly into segments and EVERY segment parses as a complete,
303
+ recognized staging or integration invocation carrying at least one pathspec operand,
304
+ with every operand resolving inside one of the five exempt orchestration-bookkeeping
305
+ trees after balanced-quote stripping and separator normalization.
306
+
307
+ The all-segments reading is deliberate and fail-closed: a chained line denies unless
308
+ each of its segments is independently a recognized all-exempt invocation, so a
309
+ relocating spelling or a prose fragment anywhere in the line withholds the exemption
310
+ even when another segment on the same line would have qualified on its own.
311
+
312
+ Consumed allow-side only. A false result restores the caller's unchanged
313
+ classification; it never suppresses the trigger.
314
+ .OUTPUTS
315
+ System.Boolean
316
+ #>
317
+ [CmdletBinding()]
318
+ [OutputType([bool])]
319
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
320
+
321
+ if (-not $CommandText) {
322
+ return $false
323
+ }
324
+
325
+ # Row 12: interpolation and redirection are not statically resolvable, so the operand
326
+ # list cannot be trusted to describe what the line actually touches.
327
+ if ($CommandText.IndexOfAny($script:UnresolvableCommandCharacters) -ge 0) {
328
+ return $false
329
+ }
330
+
331
+ $split = Split-OrchestrationCommandLine -CommandText $CommandText
332
+ if (-not $split.Balanced) {
333
+ # Rows 11 and 13: unbalanced quoting makes the segment boundaries ambiguous.
334
+ return $false
335
+ }
336
+
337
+ $segments = @($split.Segments)
338
+ if ($segments.Count -eq 0) {
339
+ return $false
340
+ }
341
+
342
+ foreach ($segment in $segments) {
343
+ $tokens = @(ConvertTo-OrchestrationCommandToken -Segment $segment)
344
+ if (-not (Test-ExemptOrchestrationSegmentToken -Token $tokens)) {
345
+ return $false
346
+ }
347
+ }
348
+ return $true
349
+ }
@@ -7,8 +7,38 @@ param()
7
7
 
8
8
 
9
9
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
10
+
11
+ # Pure pathspec classifier for the issue #539 orchestration-bookkeeping staging exemption.
12
+ # Extracted to a dot-sourced sibling so this file stays inside the 500-line cap, following
13
+ # the enforce-pr-author-skill.ps1 headroom-split precedent.
14
+ . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-helpers.ps1')
15
+
16
+ # The readiness checkpoint this gate reads and names in its block message.
10
17
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
11
18
 
19
+ # Every orchestration checkpoint a planner or orchestrator surface writes. Writing one
20
+ # of these is orchestration bookkeeping, not implementation, so the gate must not
21
+ # require a ready checkpoint before the checkpoint itself can be created. The set is a
22
+ # list of repo-relative literals behind a single membership check: no directory prefix,
23
+ # no glob, and no absolute-path entry.
24
+ $script:CheckpointPaths = @(
25
+ 'artifacts/orchestration/orchestrator-state.json'
26
+ 'artifacts/orchestration/parallel-planner-state.json'
27
+ 'artifacts/orchestration/parallel-orchestrator-state.json'
28
+ 'artifacts/orchestration/epic-planner-state.json'
29
+ 'artifacts/orchestration/epic-orchestrator-state.json'
30
+ 'artifacts/orchestration/powershell-orchestrator-state.json'
31
+ 'artifacts/orchestration/csharp-orchestrator-state.json'
32
+ )
33
+
34
+ # Both markers must appear in the field-scoped prompt for a delegation to qualify as a
35
+ # preparation-mode kickoff. The literals are reused verbatim from
36
+ # .claude/skills/parallel-plan/SKILL.md and .claude/skills/epic-plan/SKILL.md.
37
+ $script:PreparationModeMarkers = @(
38
+ 'Preparation mode: true.'
39
+ 'route_id: preparation.'
40
+ )
41
+
12
42
  function ConvertFrom-CheckpointJson {
13
43
  [CmdletBinding()]
14
44
  param([Parameter(Mandatory)][string] $Json)
@@ -35,7 +65,13 @@ function Test-FeatureDocumentationOrEvidencePath {
35
65
  [OutputType([bool])]
36
66
  param([Parameter(Mandatory)][string] $NormalizedPath)
37
67
 
38
- return $NormalizedPath.StartsWith('docs/features/active/')
68
+ # The segment anchor (^|/) admits both the repo-relative spelling and an
69
+ # absolute spelling of the same feature document, which the Write tool
70
+ # supplies by contract. -cmatch is deliberate and must not be normalized into
71
+ # -match: String.StartsWith is case-sensitive, so the case-sensitive operator
72
+ # is what preserves the previous semantics exactly. PowerShell -match is
73
+ # case-insensitive and would widen this predicate.
74
+ return $NormalizedPath -cmatch '(^|/)docs/features/active/'
39
75
  }
40
76
 
41
77
  function Test-ImplementationPath {
@@ -46,8 +82,29 @@ function Test-ImplementationPath {
46
82
  if (Test-FeatureDocumentationOrEvidencePath -NormalizedPath $NormalizedPath) {
47
83
  return $false
48
84
  }
49
- if ($NormalizedPath -eq $script:CheckpointPath) {
50
- return $false
85
+ # Segment-anchored and end-anchored, so an absolute spelling of a checkpoint is
86
+ # exempt exactly as its repo-relative spelling already was. -match is
87
+ # deliberate here and must not be narrowed into -cmatch: -contains was
88
+ # case-insensitive, so the case-insensitive operator is what preserves the
89
+ # previous semantics exactly.
90
+ #
91
+ # Accepted widening: this also exempts a path OUTSIDE the workspace whose tail
92
+ # is an artifacts/orchestration/ segment followed by one of the seven names.
93
+ # Measured exposure in this repository is one matching file, the real
94
+ # checkpoint; there is no nested or vendored second copy. The same widening is
95
+ # already accepted for the identical literal in four other hooks. Resolving a
96
+ # workspace root instead would reintroduce every root-resolution failure mode
97
+ # (8.3 short names, drive-letter case, symlinks, linked worktrees), and a strip
98
+ # that failed to match would leave the path absolute and deny, reinstating the
99
+ # reported defect in a subtler form.
100
+ #
101
+ # Known deliberate miss: a path reaching a checkpoint name only through a '..'
102
+ # hop stays denied. The Write tool does not emit '..' segments, and a
103
+ # canonicalizer would reintroduce filesystem dependence for no measured gain.
104
+ foreach ($checkpoint in $script:CheckpointPaths) {
105
+ if ($NormalizedPath -match ('(^|/)' + [regex]::Escape($checkpoint) + '$')) {
106
+ return $false
107
+ }
51
108
  }
52
109
  return $NormalizedPath -match '\.(py|ps1|psm1|ts|tsx|js|jsx|cs|json|yml|yaml)$'
53
110
  }
@@ -70,14 +127,58 @@ function Test-ImplementationCommand {
70
127
  '(^|\s)pwsh\s+.*(Invoke-Pester|tests/scripts/)'
71
128
  )
72
129
 
73
- foreach ($pattern in $implementationCommandPatterns) {
74
- if ($normalizedCommand -match $pattern) {
75
- return $true
130
+ for ($index = 0; $index -lt $implementationCommandPatterns.Count; $index++) {
131
+ if ($normalizedCommand -notmatch $implementationCommandPatterns[$index]) {
132
+ continue
133
+ }
134
+ # Allow-side only (issue #539). Index 0 is the git staging trigger, whose pattern
135
+ # text is unchanged. It is the sole leg the orchestration-bookkeeping exemption may
136
+ # clear, and only when no other implementation pattern matches the same line: the
137
+ # loop continues rather than returning, so a chained line carrying any non-git
138
+ # implementation segment still classifies as implementation.
139
+ if ($index -eq 0 -and (Test-ExemptOrchestrationStagingCommand -CommandText $normalizedCommand)) {
140
+ continue
76
141
  }
142
+ return $true
77
143
  }
78
144
  return $false
79
145
  }
80
146
 
147
+ function Test-PreparationModeDelegation {
148
+ <#
149
+ .SYNOPSIS
150
+ Identifies an orchestrator delegation that is a preparation-mode kickoff.
151
+ .DESCRIPTION
152
+ Returns true only when all three conjuncts hold: the payload is present, the
153
+ delegated agent is exactly 'orchestrator', and the field-scoped prompt carries
154
+ both preparation markers. The prompt is read as a named field rather than from
155
+ the serialized payload so that marker text planted in an unrelated field cannot
156
+ exempt an implementation delegation.
157
+ .OUTPUTS
158
+ System.Boolean
159
+ #>
160
+ [CmdletBinding()]
161
+ [OutputType([bool])]
162
+ param([Parameter(Mandatory)][AllowNull()] $ToolInput)
163
+
164
+ if ($null -eq $ToolInput) {
165
+ return $false
166
+ }
167
+
168
+ $subagentType = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'subagent_type'
169
+ if ($subagentType -ne 'orchestrator') {
170
+ return $false
171
+ }
172
+
173
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'prompt'
174
+ foreach ($marker in $script:PreparationModeMarkers) {
175
+ if (-not $prompt.Contains($marker)) {
176
+ return $false
177
+ }
178
+ }
179
+ return $true
180
+ }
181
+
81
182
  function Test-ImplementationDelegation {
82
183
  [CmdletBinding()]
83
184
  [OutputType([bool])]
@@ -87,6 +188,17 @@ function Test-ImplementationDelegation {
87
188
  return $false
88
189
  }
89
190
 
191
+ try {
192
+ if (Test-PreparationModeDelegation -ToolInput $ToolInput) {
193
+ return $false
194
+ }
195
+ } catch {
196
+ # An envelope the field reader cannot probe falls through to the unchanged
197
+ # whole-payload regex below. An extraction failure must never become an
198
+ # exemption, so the gate stays closed on the stricter classifier.
199
+ Write-Debug "Preparation-mode probe failed: $($_.Exception.Message)"
200
+ }
201
+
90
202
  $payloadText = ($ToolInput | ConvertTo-Json -Depth 20 -Compress)
91
203
  return $payloadText -match '(python-typed-engineer|powershell-typed-engineer|typescript-engineer|csharp-typed-engineer|atomic-executor|implementation|execute)'
92
204
  }