@danmoisan/drm-copilot-mcp 1.1.5 → 1.1.7

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 (27) hide show
  1. package/out/mcp-server.js +49 -14
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/atomic-planner.md +2 -0
  4. package/resources/claude-customizations/.claude/agents/prd-feature.md +5 -0
  5. package/resources/claude-customizations/.claude/agents/task-researcher.md +3 -0
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +163 -18
  7. package/resources/claude-customizations/.claude/hooks/validate-planner-output.ps1 +122 -0
  8. package/resources/claude-customizations/.claude/hooks/validate-prd-feature-output.ps1 +91 -0
  9. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +69 -1
  10. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +11 -0
  11. package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +32 -0
  12. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +1 -0
  13. package/resources/claude-customizations/.claude/settings.json +9 -0
  14. package/resources/claude-customizations/.claude/skills/acceptance-criteria-tracking/SKILL.md +2 -0
  15. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +32 -0
  16. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +136 -4
  17. package/resources/claude-customizations/.claude/skills/fill-feature-docs/SKILL.md +3 -0
  18. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +5 -1
  19. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +18 -9
  20. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +10 -1
  21. package/resources/claude-customizations/.claude/skills/pr-context-artifacts/SKILL.md +22 -0
  22. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +12 -0
  23. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +3 -0
  24. package/resources/claude-customizations/pack-manifests/core.json +2 -0
  25. package/resources/codex-and-agents-customizations/.agents/skills/pr-context-artifacts/SKILL.md +22 -0
  26. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  27. package/resources/customizations/.github/skills/pr-context-artifacts/SKILL.md +22 -0
package/out/mcp-server.js CHANGED
@@ -31365,10 +31365,16 @@ function parseSection3(markdown, heading) {
31365
31365
  function formatDiffPath2(pathText) {
31366
31366
  return pathText !== null ? formatDiffPath(pathText) : "";
31367
31367
  }
31368
- function appendGenerationTimestamp(clock = () => /* @__PURE__ */ new Date()) {
31368
+ var GENERATED_CONTEXT_SECTION_TITLE = "Context generated";
31369
+ var HEAD_SHA_LABEL = "Head SHA:";
31370
+ var UNKNOWN_HEAD_SHA_PLACEHOLDER = "(unknown)";
31371
+ function appendGenerationTimestamp(clock = () => /* @__PURE__ */ new Date(), headSha = null) {
31369
31372
  const now = clock();
31370
31373
  const timestamp = formatUtcTimestamp(now);
31371
- return section("Context generated") + "\n" + timestamp + "\n";
31374
+ const shaText = headSha !== null && headSha !== "" ? headSha : UNKNOWN_HEAD_SHA_PLACEHOLDER;
31375
+ return section(GENERATED_CONTEXT_SECTION_TITLE) + "\n" + timestamp + `
31376
+ ${HEAD_SHA_LABEL} ${shaText}
31377
+ `;
31372
31378
  }
31373
31379
  function formatUtcTimestamp(date3) {
31374
31380
  const year = date3.getUTCFullYear().toString().padStart(4, "0");
@@ -31716,7 +31722,7 @@ function renderVerificationEvidenceSection(fs10, resolvedRoot, featureDocs) {
31716
31722
  }
31717
31723
  return lines.join("\n");
31718
31724
  }
31719
- function buildSummaryText(collected, fs10, appendixPath) {
31725
+ function buildSummaryText(collected, fs10, appendixPath, generatedSection) {
31720
31726
  const ctx = collected.contextResult;
31721
31727
  const ghStatusText = resolveGhStatusText(collected);
31722
31728
  const intentBlock = [
@@ -31727,6 +31733,7 @@ function buildSummaryText(collected, fs10, appendixPath) {
31727
31733
  "Author-asserted autoclose issues:"
31728
31734
  ].join("\n");
31729
31735
  const summarySections = [
31736
+ generatedSection,
31730
31737
  section("GitHub CLI status"),
31731
31738
  ghStatusText,
31732
31739
  intentBlock,
@@ -31804,7 +31811,7 @@ function buildSummaryText(collected, fs10, appendixPath) {
31804
31811
  }
31805
31812
  return summaryText;
31806
31813
  }
31807
- function buildAppendixText(collected, clock) {
31814
+ function buildAppendixText(collected, generatedSection) {
31808
31815
  const featureBlock = collected.featureDocs.map((doc) => doc.excerpt).join("\n");
31809
31816
  const issueSections = collected.issueDetails.map(
31810
31817
  (detail) => issueAppendix(detail)
@@ -31813,7 +31820,7 @@ function buildAppendixText(collected, clock) {
31813
31820
  (detail) => prAppendix(detail)
31814
31821
  );
31815
31822
  const appendixParts = [
31816
- appendGenerationTimestamp(clock),
31823
+ generatedSection,
31817
31824
  collected.contextResult.text,
31818
31825
  "",
31819
31826
  section("Issue details"),
@@ -31846,17 +31853,23 @@ function writeOutput(fs10, text, outPath, append2) {
31846
31853
  function collectAndWrite(options) {
31847
31854
  const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
31848
31855
  const collected = collectPrContext(options);
31856
+ const generatedSection = appendGenerationTimestamp(
31857
+ clock,
31858
+ collected.contextResult.headSha
31859
+ );
31849
31860
  const summaryText = buildSummaryText(
31850
31861
  collected,
31851
31862
  options.fs,
31852
- options.appendixOut
31863
+ options.appendixOut,
31864
+ generatedSection
31853
31865
  );
31854
- const appendixText = buildAppendixText(collected, clock);
31866
+ const appendixText = buildAppendixText(collected, generatedSection);
31855
31867
  writeOutput(options.fs, summaryText, options.out, options.append);
31856
31868
  writeOutput(options.fs, appendixText, options.appendixOut, options.append);
31857
31869
  const log = options.log ?? (() => void 0);
31858
31870
  log(`Wrote context summary to: ${options.out}`);
31859
31871
  log(`Wrote context appendix to: ${options.appendixOut}`);
31872
+ return { summaryText, appendixText };
31860
31873
  }
31861
31874
  function resolveGhStatusText(collected) {
31862
31875
  let ghStatusText = collected.ghStatusOverride || collected.ghStatusMessage || "GitHub CLI authenticated.";
@@ -31928,26 +31941,48 @@ function parentDir(path11) {
31928
31941
  // ../../extensions/drm-copilot/src/lib/pr-context/pr-context-service-call.ts
31929
31942
  var SUMMARY_OUT = "artifacts/pr_context.summary.txt";
31930
31943
  var APPENDIX_OUT = "artifacts/pr_context.appendix.txt";
31944
+ function verifyWrittenArtifact(fileSystem, artifactPath, expected) {
31945
+ let actual;
31946
+ try {
31947
+ actual = fileSystem.readTextFile(artifactPath);
31948
+ } catch (error2) {
31949
+ const detail = error2 instanceof Error ? error2.message : String(error2);
31950
+ throw new Error(
31951
+ `Failed to verify PR context artifact '${artifactPath}': the file could not be read back after writing (${detail}).`,
31952
+ { cause: error2 }
31953
+ );
31954
+ }
31955
+ if (actual !== expected) {
31956
+ throw new Error(
31957
+ `Failed to verify PR context artifact '${artifactPath}': the content read back is not the content this invocation rendered (expected ${String(expected.length)} characters, read back ${String(actual.length)}).`
31958
+ );
31959
+ }
31960
+ }
31931
31961
  function collectPrContextServiceCall(input) {
31932
- collectAndWrite({
31962
+ const summaryOut = normalizeGeneratedPath(
31963
+ (0, import_node_path.join)(input.workspaceRoot, SUMMARY_OUT)
31964
+ );
31965
+ const appendixOut = normalizeGeneratedPath(
31966
+ (0, import_node_path.join)(input.workspaceRoot, APPENDIX_OUT)
31967
+ );
31968
+ const rendered = collectAndWrite({
31933
31969
  base: input.base,
31934
31970
  repoRoot: input.workspaceRoot,
31935
- out: SUMMARY_OUT,
31936
- appendixOut: APPENDIX_OUT,
31971
+ out: summaryOut,
31972
+ appendixOut,
31937
31973
  append: false,
31938
31974
  includeUntracked: true,
31939
31975
  fs: input.fileSystem,
31940
31976
  runner: input.runner,
31941
31977
  ...input.log === void 0 ? {} : { log: input.log }
31942
31978
  });
31979
+ verifyWrittenArtifact(input.fileSystem, summaryOut, rendered.summaryText);
31980
+ verifyWrittenArtifact(input.fileSystem, appendixOut, rendered.appendixText);
31943
31981
  return {
31944
31982
  tool: "collect_pr_context",
31945
31983
  workspaceRoot: input.workspaceRoot,
31946
31984
  summary: `Collected PR context against base '${input.base}'.`,
31947
- artifacts: [
31948
- normalizeGeneratedPath((0, import_node_path.join)(input.workspaceRoot, SUMMARY_OUT)),
31949
- normalizeGeneratedPath((0, import_node_path.join)(input.workspaceRoot, APPENDIX_OUT))
31950
- ]
31985
+ artifacts: [summaryOut, appendixOut]
31951
31986
  };
31952
31987
  }
31953
31988
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -57,6 +57,8 @@ Generate plans using the atomic plan contract defined in the `atomic-plan-contra
57
57
 
58
58
  ## Preflight Validation
59
59
 
60
+ Before handing the plan to executor preflight, return the exact bounded line record defined in `atomic-plan-contract`, starting with `PLANNER-INTERNAL-REVIEW: PASS` and ending with the required `PREFLIGHT:` signal. The bounded record must include exactly one passing `CITATION-TO-TREE`, `AC-TRACEABILITY`, and `SCOPE-BOUNDARY` declaration; one or more current-tree `CITATION: <repository-relative path> | <locator>` records; one complete unique `AC-INVENTORY:`; one complete `AC-MAPPING: <ID> | IMPLEMENTATION: <identifier> | TESTS: <identifier> | EVIDENCE: <identifier>` for every and only inventory ID; and exactly one `UNRESOLVED-GAPS: NONE`. A missing, duplicate, malformed, failed, blocked, or out-of-bounds record declaration requires `SELF-REVIEW: BLOCKED` and stops handoff.
61
+
60
62
  Return the finalized plan for validation-only preflight through `atomic-executor` and preserve the same target file path across revision loops. Do not claim nested worker delegation from within planner execution.
61
63
 
62
64
  ## Output
@@ -16,6 +16,8 @@ hooks:
16
16
  hooks:
17
17
  - type: command
18
18
  command: pwsh -NoProfile -File .claude/hooks/validate-required-artifact-output.ps1 -AgentName prd-feature -RequiredArtifact 'spec-path|^docs/features/active/.+/spec\.md$|feature spec artifact' -RequiredArtifact 'user-story-path|^docs/features/active/.+/user-story\.md$|feature user story artifact'
19
+ - type: command
20
+ command: pwsh -NoProfile -File .claude/hooks/validate-prd-feature-output.ps1
19
21
  ---
20
22
 
21
23
  # PRD Feature Agent
@@ -27,12 +29,15 @@ Produce feature-document outputs for the active feature folder.
27
29
  - `docs/features/active/<feature>/spec.md`
28
30
  - `docs/features/active/<feature>/user-story.md`
29
31
 
32
+ When an approved `spec.md` acceptance criterion contains a numeric count, enumeration, or population, require the supplied research record to include complete `## Numeric Derivation Evidence`: `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. The two derivations must be non-empty, independently constructed, use distinct search strategies or query expressions, independently enumerate member sets, and explicitly compare those sets. The exhaustive scope must cover the complete family, including all relevant overloads and members. Omit the numeric assertion when the record is missing, repeated, incomplete, non-exhaustive, narrow, or disagrees; equal totals, distinct query text, or equal member sets alone are insufficient.
33
+
30
34
  ## Output Reporting
31
35
 
32
36
  Report the final artifact paths as:
33
37
 
34
38
  - `spec-path: docs/features/active/<feature>/spec.md`
35
39
  - `user-story-path: docs/features/active/<feature>/user-story.md`
40
+ - `research-path: docs/features/active/<feature>/research/<timestamp>-<short-name>-research.md` when numeric acceptance criteria are present
36
41
 
37
42
  ## Evidence Location Invariant
38
43
 
@@ -64,6 +64,8 @@ The orchestrator resolves which root to use from whether an active feature folde
64
64
  ### 4. Requirements Mapping
65
65
 
66
66
  - Map acceptance criteria into a concrete design with proposed state model, transitions, and required file changes.
67
+ - Before a numeric count, enumeration, or population can be proposed for an approved `spec.md` acceptance criterion, add a complete `## Numeric Derivation Evidence` section. For each numeric claim, record `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`.
68
+ - The primary and cross-check records must both be non-empty, independently enumerate their member sets, and name distinct search strategies or query expressions. The exhaustive scope must cover the complete family, including every relevant overload or member; a single grep or query that matches only one named pattern in the declared family is insufficient even if it yields the same count. Explicitly compare the normalized primary and cross-check member sets before proposing the numeric assertion. Withhold the assertion if either record is missing, incomplete, duplicated, non-exhaustive, narrow, or disagrees.
67
69
 
68
70
  ### 5. Testing Implications
69
71
 
@@ -75,6 +77,7 @@ The orchestrator resolves which root to use from whether an active feature folde
75
77
  - Ground all findings in verified evidence.
76
78
  - Keep discussion of non-selected approaches brief.
77
79
  - Do not claim nested worker delegation.
80
+ - Do not present a numeric `spec.md` acceptance criterion when the required numeric derivation evidence is absent, incomplete, or has disagreeing counts.
78
81
 
79
82
  ## Evidence Location Invariant
80
83
 
@@ -4,14 +4,51 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
- command runs. Regex-matches git worktree remove against the envelope's tool_input.command,
8
- extracts the target worktree path argument, reads
9
- artifacts/orchestration/epic-orchestrator-state.json, and finds the features[] record
10
- whose worktree_path matches. Allows removal only when that record's merge_status is
11
- merged or worktree_removed. Denies with reason EPIC_WORKTREE_REMOVAL_BLOCKED when the
12
- checkpoint is unreadable, no matching record exists, or merge_status is anything else -
13
- fail-closed, following the enforce-orchestration-preimplementation-gate.ps1 precedent of
14
- treating an unreadable/no-match checkpoint as deny.
7
+ command runs. Regex-matches git worktree remove against the envelope's
8
+ tool_input.command, extracts the target worktree path argument, and allows the
9
+ removal only when one of two checkpoint-only conditions holds. The branches are
10
+ evaluated in this order and are ORed, not ANDed: the first that authorizes wins and
11
+ the second is not consulted.
12
+
13
+ 1. Epic path: artifacts/orchestration/epic-orchestrator-state.json carries a
14
+ features[] record whose worktree_path matches the removal target and whose
15
+ merge_status is merged or worktree_removed.
16
+ 2. Parallel path: artifacts/orchestration/parallel-orchestrator-state.json has
17
+ route_id == "parallel" and carries an items[] entry whose worktree_path matches
18
+ the removal target and whose merge_status is merged or worktree_removed.
19
+
20
+ Otherwise the command is denied with reason EPIC_WORKTREE_REMOVAL_BLOCKED. The gate
21
+ is fail-closed in every failure mode: neither checkpoint present, either checkpoint
22
+ unparseable, route_id absent or not "parallel", no matching worktree_path, a matched
23
+ record whose merge_status is not in the allowed set, or a matched record carrying no
24
+ merge_status key. The cascade is a disjunction of two positive predicates; no
25
+ negative path returns an allow. An envelope-level anomaly is checked first and denies
26
+ before either checkpoint is read.
27
+
28
+ Both branches key on the worktree PATH because the command names a path, and both
29
+ normalize separators and trim a trailing slash on each side of the comparison, so
30
+ Windows- and POSIX-style paths compare equal. Path is also why the two branches are
31
+ mutually exclusive in practice: an epic run and a parallel run allocate worktrees
32
+ under distinct per-run, per-item paths, so a path recorded in one checkpoint is not a
33
+ path recorded in the other, and adding branch 2 cannot widen what branch 1 authorizes.
34
+ The authorization is a property of the path rather than of the caller, and the safety
35
+ property this gate protects - do not destroy unmerged work - is likewise a property of
36
+ the path's recorded merge state.
37
+
38
+ Accepted residual: artifacts/ is gitignored, so the parallel checkpoint persists after
39
+ a run ends and a stale document remains readable here. For a stale document to
40
+ authorize a removal it should not, a worktree would have to exist at a path
41
+ byte-identical, after normalization, to one it records with merge_status merged or
42
+ worktree_removed. Worktree paths carry a session or timestamp component, so a
43
+ collision is implausible, and where the recorded status is worktree_removed the path
44
+ was already deleted. This is a documented accepted trade, not an unexamined gap; the
45
+ route_id check does not reduce it, because a stale parallel checkpoint legitimately
46
+ declares that route.
47
+
48
+ The sibling gate enforce-parallel-worktree-removal-gate.ps1 fires on the same command
49
+ and owns its own reason prefix. PreToolUse denials are conjunctive, so both gates must
50
+ allow for a removal to proceed; this gate keeps the EPIC_WORKTREE_REMOVAL_BLOCKED
51
+ prefix for both of its branches so transcript attribution stays unambiguous.
15
52
 
16
53
  .NOTES
17
54
  Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
@@ -24,6 +61,7 @@ param()
24
61
 
25
62
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
26
63
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
64
+ $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
27
65
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
28
66
 
29
67
  function Get-EpicWorktreeGateCheckpointContent {
@@ -44,6 +82,52 @@ function Get-EpicWorktreeGateCheckpointContent {
44
82
  return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
45
83
  }
46
84
 
85
+ function Get-EpicWorktreeGateParallelCheckpointContent {
86
+ <#
87
+ .SYNOPSIS
88
+ Read the raw JSON text of the parallel-orchestrator checkpoint. Tests mock
89
+ this function (read seam).
90
+ .OUTPUTS
91
+ System.String or $null
92
+ #>
93
+ [CmdletBinding()]
94
+ [OutputType([string])]
95
+ param()
96
+
97
+ if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
98
+ return $null
99
+ }
100
+ return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
101
+ }
102
+
103
+ function ConvertFrom-EpicWorktreeGateJson {
104
+ <#
105
+ .SYNOPSIS
106
+ Parse checkpoint JSON text, returning $null on unreadable/invalid content.
107
+ .DESCRIPTION
108
+ Shared by both authorization branches so the two checkpoints are parsed by
109
+ one implementation rather than by duplicated inline logic.
110
+ .PARAMETER Raw
111
+ Raw checkpoint text, or $null when the file does not exist.
112
+ .OUTPUTS
113
+ System.Object or $null
114
+ #>
115
+ [CmdletBinding()]
116
+ param(
117
+ [AllowNull()]
118
+ [string] $Raw
119
+ )
120
+
121
+ if ([string]::IsNullOrWhiteSpace($Raw)) {
122
+ return $null
123
+ }
124
+ try {
125
+ return ($Raw | ConvertFrom-Json -ErrorAction Stop)
126
+ } catch {
127
+ return $null
128
+ }
129
+ }
130
+
47
131
  function Get-EpicWorktreeRemovalCommandPath {
48
132
  <#
49
133
  .SYNOPSIS
@@ -138,6 +222,70 @@ function Test-EpicWorktreeRemovalAllowed {
138
222
  return $script:AllowedMergeStatuses -contains ([string]$FeatureRecord.merge_status)
139
223
  }
140
224
 
225
+ function Test-ParallelCheckpointAllowsWorktreeRemoval {
226
+ <#
227
+ .SYNOPSIS
228
+ Decision logic for the parallel-orchestrator checkpoint path (branch 2).
229
+ .DESCRIPTION
230
+ Allows only when the checkpoint declares the parallel route identity and
231
+ carries an items[] entry whose worktree_path matches the removal target and
232
+ whose merge_status is in the allowed set. Every other shape returns $false,
233
+ so the branch is a positive predicate with no negative path to an allow.
234
+ .PARAMETER Checkpoint
235
+ Parsed parallel-orchestrator checkpoint, or $null when absent/unreadable.
236
+ .PARAMETER WorktreePath
237
+ The target worktree path extracted from the command text.
238
+ .OUTPUTS
239
+ System.Boolean
240
+ #>
241
+ [CmdletBinding()]
242
+ [OutputType([bool])]
243
+ param(
244
+ [AllowNull()]
245
+ $Checkpoint,
246
+
247
+ [AllowNull()]
248
+ [string] $WorktreePath
249
+ )
250
+
251
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
252
+ return $false
253
+ }
254
+ $props = @($Checkpoint.PSObject.Properties.Name)
255
+ # Route identity is orchestrator invariant 2, so a document at the parallel path
256
+ # that does not declare it is malformed by the rule's own definition and inert here.
257
+ if ($props -notcontains 'route_id' -or ([string]$Checkpoint.route_id) -ne 'parallel') {
258
+ return $false
259
+ }
260
+ if ($props -notcontains 'items' -or $null -eq $Checkpoint.items) {
261
+ return $false
262
+ }
263
+
264
+ $normalizedTarget = ($WorktreePath -replace '\\', '/').TrimEnd('/')
265
+
266
+ # Scan every recorded item for a worktree_path that matches the removal target;
267
+ # path separators are normalized exactly as the epic branch normalizes them.
268
+ foreach ($item in @($Checkpoint.items)) {
269
+ if ($null -eq $item) {
270
+ continue
271
+ }
272
+ $itemProps = @($item.PSObject.Properties.Name)
273
+ if ($itemProps -notcontains 'worktree_path') {
274
+ continue
275
+ }
276
+ $normalizedItemPath = (([string]$item.worktree_path) -replace '\\', '/').TrimEnd('/')
277
+ if ($normalizedItemPath -ne $normalizedTarget) {
278
+ continue
279
+ }
280
+ if ($itemProps -notcontains 'merge_status') {
281
+ return $false
282
+ }
283
+ return $script:AllowedMergeStatuses -contains ([string]$item.merge_status)
284
+ }
285
+
286
+ return $false
287
+ }
288
+
141
289
  function Get-EpicWorktreeGateAllowDecision {
142
290
  [CmdletBinding()]
143
291
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -202,22 +350,19 @@ function Invoke-EpicWorktreeRemovalGateDecision {
202
350
 
203
351
  $worktreePath = Get-EpicWorktreeRemovalCommandPath -CommandText $commandText
204
352
 
205
- $checkpointRaw = Get-EpicWorktreeGateCheckpointContent
206
- $checkpoint = $null
207
- if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
208
- try {
209
- $checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
210
- } catch {
211
- $checkpoint = $null
212
- }
213
- }
353
+ $checkpoint = ConvertFrom-EpicWorktreeGateJson -Raw (Get-EpicWorktreeGateCheckpointContent)
214
354
 
215
355
  $featureRecord = Find-EpicWorktreeFeatureRecord -Checkpoint $checkpoint -WorktreePath $worktreePath
216
356
  if (Test-EpicWorktreeRemovalAllowed -FeatureRecord $featureRecord) {
217
357
  return Get-EpicWorktreeGateAllowDecision
218
358
  }
219
359
 
220
- return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires a matching epic checkpoint features[] record with merge_status in {merged, worktree_removed}. The checkpoint was unreadable, no matching record was found, or merge_status was not yet safe for removal."
360
+ $parallelCheckpoint = ConvertFrom-EpicWorktreeGateJson -Raw (Get-EpicWorktreeGateParallelCheckpointContent)
361
+ if (Test-ParallelCheckpointAllowsWorktreeRemoval -Checkpoint $parallelCheckpoint -WorktreePath $worktreePath) {
362
+ return Get-EpicWorktreeGateAllowDecision
363
+ }
364
+
365
+ return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires either an epic checkpoint features[] record with merge_status in {merged, worktree_removed}, or a parallel-orchestrator checkpoint with route_id == ""parallel"" whose matching items[] record (matched by worktree_path) has merge_status in {merged, worktree_removed}. No checkpoint authorized this removal."
221
366
  }
222
367
 
223
368
  function Invoke-EpicWorktreeRemovalGateEntryPoint {
@@ -110,6 +110,123 @@ function Test-HasPreflightSignal {
110
110
  )
111
111
  }
112
112
 
113
+ function Get-PlannerInternalReviewValidation {
114
+ [CmdletBinding()]
115
+ [OutputType([hashtable])]
116
+ param([Parameter(Mandatory = $true)][string] $AgentOutput)
117
+
118
+ $lines = @($AgentOutput -split "`r?`n")
119
+ $labelPattern = '^\s*(?:PLANNER-INTERNAL-REVIEW|CITATION-TO-TREE|AC-TRACEABILITY|SCOPE-BOUNDARY|CITATION|AC-INVENTORY|AC-MAPPING|UNRESOLVED-GAPS|PREFLIGHT)\s*:'
120
+ $headerPattern = '^\s*PLANNER-INTERNAL-REVIEW\s*:\s*(?<Value>.*?)\s*$'
121
+ $preflightPattern = '^\s*PREFLIGHT\s*:\s*(?:ALL CLEAR|REVISIONS REQUIRED)\s*$'
122
+ $labelIndexes = [System.Collections.Generic.List[int]]::new()
123
+ $headerIndexes = [System.Collections.Generic.List[int]]::new()
124
+ $preflightIndexes = [System.Collections.Generic.List[int]]::new()
125
+
126
+ for ($index = 0; $index -lt $lines.Count; $index++) {
127
+ if ($lines[$index] -match $labelPattern) {
128
+ $labelIndexes.Add($index)
129
+ }
130
+ if ($lines[$index] -match $headerPattern) {
131
+ $headerIndexes.Add($index)
132
+ }
133
+ if ($lines[$index] -match $preflightPattern) {
134
+ $preflightIndexes.Add($index)
135
+ }
136
+ }
137
+
138
+ if ($headerIndexes.Count -ne 1) {
139
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one `PLANNER-INTERNAL-REVIEW:` declaration.' }
140
+ }
141
+
142
+ $headerIndex = $headerIndexes[0]
143
+ $headerMatch = [regex]::Match($lines[$headerIndex], $headerPattern)
144
+ if ($headerMatch.Groups['Value'].Value.Trim() -ne 'PASS') {
145
+ return @{ Ok = $false; Message = 'planner internal review header must be exactly `PLANNER-INTERNAL-REVIEW: PASS`.' }
146
+ }
147
+
148
+ $preflightAfterHeader = @($preflightIndexes | Where-Object { $_ -gt $headerIndex })
149
+ if ($preflightAfterHeader.Count -ne 1) {
150
+ return @{ Ok = $false; Message = 'planner internal review must terminate at exactly one required `PREFLIGHT:` signal after its header.' }
151
+ }
152
+
153
+ $preflightIndex = $preflightAfterHeader[0]
154
+ foreach ($labelIndex in $labelIndexes) {
155
+ if ($labelIndex -lt $headerIndex -or $labelIndex -gt $preflightIndex) {
156
+ return @{ Ok = $false; Message = 'planner internal review declarations must not occur outside the bounded record.' }
157
+ }
158
+ }
159
+
160
+ $recordLines = @($lines[$headerIndex..$preflightIndex])
161
+ $requiredDeclarations = @('CITATION-TO-TREE', 'AC-TRACEABILITY', 'SCOPE-BOUNDARY')
162
+ foreach ($declaration in $requiredDeclarations) {
163
+ $declarationMatches = @($recordLines | Where-Object { $_ -match "^\s*$declaration\s*:" })
164
+ if ($declarationMatches.Count -ne 1) {
165
+ return @{ Ok = $false; Message = "planner internal review must contain exactly one `$declaration: PASS` declaration." }
166
+ }
167
+ if ($declarationMatches[0] -notmatch "^\s*$declaration\s*:\s*PASS\s*$") {
168
+ return @{ Ok = $false; Message = "planner internal review declaration `$declaration must be exactly PASS." }
169
+ }
170
+ }
171
+
172
+ $citationLines = @($recordLines | Where-Object { $_ -match '^\s*CITATION\s*:' })
173
+ if ($citationLines.Count -eq 0) {
174
+ return @{ Ok = $false; Message = 'planner internal review must contain at least one `CITATION:` record.' }
175
+ }
176
+ foreach ($citationLine in $citationLines) {
177
+ $citation = [regex]::Match($citationLine, '^\s*CITATION\s*:\s*(?<Path>[^|\s]+)\s*\|\s*(?<Locator>.+?\S)\s*$')
178
+ if (-not $citation.Success -or $citation.Groups['Path'].Value -notmatch '^(?![A-Za-z]:)(?!/)(?!\\)(?:\.?[^/\\|\s]+)(?:/[^/\\|\s]+)+$') {
179
+ return @{ Ok = $false; Message = 'planner internal review citations require a repository-relative path and nonblank locator.' }
180
+ }
181
+ }
182
+
183
+ $inventoryLines = @($recordLines | Where-Object { $_ -match '^\s*AC-INVENTORY\s*:' })
184
+ if ($inventoryLines.Count -ne 1) {
185
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one nonblank `AC-INVENTORY:` declaration.' }
186
+ }
187
+ $inventoryValue = ($inventoryLines[0] -replace '^\s*AC-INVENTORY\s*:\s*', '').Trim()
188
+ if ([string]::IsNullOrWhiteSpace($inventoryValue)) {
189
+ return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
190
+ }
191
+ $inventoryIds = @($inventoryValue -split ',' | ForEach-Object { $_.Trim() })
192
+ if ((@($inventoryIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) -or
193
+ (@($inventoryIds | Select-Object -Unique).Count -ne $inventoryIds.Count)) {
194
+ return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
195
+ }
196
+
197
+ $mappingLines = @($recordLines | Where-Object { $_ -match '^\s*AC-MAPPING\s*:' })
198
+ if ($mappingLines.Count -eq 0) {
199
+ return @{ Ok = $false; Message = 'planner internal review must contain one `AC-MAPPING:` record for every inventory ID.' }
200
+ }
201
+ $mappingIds = [System.Collections.Generic.List[string]]::new()
202
+ foreach ($mappingLine in $mappingLines) {
203
+ $mapping = [regex]::Match($mappingLine, '^\s*AC-MAPPING\s*:\s*(?<Id>[^|]*?)\s*\|\s*IMPLEMENTATION\s*:\s*(?<Implementation>[^|]*?)\s*\|\s*TESTS\s*:\s*(?<Tests>[^|]*?)\s*\|\s*EVIDENCE\s*:\s*(?<Evidence>.*?)\s*$')
204
+ if (-not $mapping.Success -or
205
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Id'].Value) -or
206
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Implementation'].Value) -or
207
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Tests'].Value) -or
208
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Evidence'].Value)) {
209
+ return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` requires nonblank ID, IMPLEMENTATION, TESTS, and EVIDENCE fields.' }
210
+ }
211
+ $mappingIds.Add($mapping.Groups['Id'].Value.Trim())
212
+ }
213
+ if (@($mappingIds | Select-Object -Unique).Count -ne $mappingIds.Count) {
214
+ return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` identifiers must be unique.' }
215
+ }
216
+ if ($inventoryIds.Count -ne $mappingIds.Count -or
217
+ (@($inventoryIds | Where-Object { $_ -notin $mappingIds }).Count -gt 0) -or
218
+ (@($mappingIds | Where-Object { $_ -notin $inventoryIds }).Count -gt 0)) {
219
+ return @{ Ok = $false; Message = 'planner internal review AC inventory and mapping identifiers must match exactly.' }
220
+ }
221
+
222
+ $gapLines = @($recordLines | Where-Object { $_ -match '^\s*UNRESOLVED-GAPS\s*:' })
223
+ if ($gapLines.Count -ne 1 -or $gapLines[0] -notmatch '^\s*UNRESOLVED-GAPS\s*:\s*NONE\s*$') {
224
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one `UNRESOLVED-GAPS: NONE` declaration.' }
225
+ }
226
+
227
+ return @{ Ok = $true; Message = $null }
228
+ }
229
+
113
230
  function Get-PlanStructureValidationReport {
114
231
  [CmdletBinding()]
115
232
  [OutputType([string[]])]
@@ -272,6 +389,11 @@ function Invoke-PlannerOutputValidation {
272
389
  return @{ Ok = $false; Message = $message }
273
390
  }
274
391
 
392
+ $review = Get-PlannerInternalReviewValidation -AgentOutput $agentOutput
393
+ if (-not $review.Ok) {
394
+ return @{ Ok = $false; Message = "atomic-planner hook: $($review.Message)" }
395
+ }
396
+
275
397
  return @{ Ok = $true; Message = $null }
276
398
  }
277
399
 
@@ -0,0 +1,91 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Validates numeric acceptance criteria produced by the prd-feature worker.
4
+ #>
5
+
6
+ [CmdletBinding()]
7
+ param()
8
+
9
+ Set-StrictMode -Version Latest
10
+ $ErrorActionPreference = 'Stop'
11
+
12
+ function Get-ArtifactPathFromOutput {
13
+ [CmdletBinding()]
14
+ [OutputType([string])]
15
+ param([Parameter(Mandatory = $true)][string] $Output, [Parameter(Mandatory = $true)][string] $Label)
16
+
17
+ $match = [regex]::Match($Output, "(?im)^\s*$([regex]::Escape($Label))\s*:\s*(?<path>\S+)")
18
+ if ($match.Success) { return $match.Groups['path'].Value }
19
+ return $null
20
+ }
21
+
22
+ function Test-NumericDerivationEvidence {
23
+ [CmdletBinding()]
24
+ [OutputType([hashtable])]
25
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Content)
26
+
27
+ $section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)')
28
+ if (-not $section.Success) { return @{ Ok = $false; Message = 'prd-feature hook: numeric criterion requires ## Numeric Derivation Evidence.' } }
29
+ $requiredLabels = @(
30
+ 'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules',
31
+ 'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count',
32
+ 'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count',
33
+ 'Member-set Comparison'
34
+ )
35
+ $values = @{}
36
+ foreach ($label in $requiredLabels) {
37
+ $match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?<value>\S(?:.*\S)?)[\t ]*$")
38
+ if (-not $match.Success) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation evidence is missing $label." } }
39
+ $values[$label] = $match.Groups['value'].Value.Trim()
40
+ }
41
+ if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence does not declare an exhaustive repository search scope.' } }
42
+ $primaryStrategy = $values['Primary Search Strategy or Query Expression']
43
+ $crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression']
44
+ if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence uses a narrow named-pattern search.' } }
45
+ if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation cross-check repeats the primary search strategy or query expression.' } }
46
+ $familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
47
+ foreach ($familyMember in $familyMembers) {
48
+ if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation search does not cover complete family member '$familyMember'." } }
49
+ }
50
+ if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation counts must be numeric.' } }
51
+ $primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
52
+ $crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
53
+ if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation count does not match its independently enumerated member set.' } }
54
+ $normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
55
+ $normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
56
+ if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation primary and cross-check member sets disagree.' } }
57
+ if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence is missing an explicit member-set comparison.' } }
58
+ return @{ Ok = $true; Message = $null }
59
+ }
60
+
61
+ function Test-SpecNumericCriterion {
62
+ [CmdletBinding()]
63
+ [OutputType([bool])]
64
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $SpecContent)
65
+
66
+ $section = [regex]::Match($SpecContent, '(?ims)^##\s+Acceptance\s+Criteria\s*$.*?(?=^##\s|\z)')
67
+ return $section.Success -and [regex]::IsMatch($section.Value, '(?m)^\s*[-*]\s*\[.\].*\b\d+\b')
68
+ }
69
+
70
+ function Invoke-PrdFeatureOutputValidation {
71
+ [CmdletBinding()]
72
+ [OutputType([hashtable])]
73
+ param([string] $RawPayload)
74
+
75
+ if ([string]::IsNullOrWhiteSpace($RawPayload)) { return @{ Ok = $false; Message = 'prd-feature hook: CLAUDE_HOOK_INPUT is empty.' } }
76
+ try { $payload = $RawPayload | ConvertFrom-Json -ErrorAction Stop } catch { return @{ Ok = $false; Message = "prd-feature hook: failed to parse CLAUDE_HOOK_INPUT as JSON: $($_.Exception.Message)" } }
77
+ $output = if ($null -ne $payload.PSObject.Properties['output']) { $payload.output } else { $null }
78
+ if ([string]::IsNullOrWhiteSpace($output)) { return @{ Ok = $false; Message = 'prd-feature hook: agent output is empty.' } }
79
+ $specPath = Get-ArtifactPathFromOutput -Output $output -Label 'spec-path'
80
+ if ([string]::IsNullOrWhiteSpace($specPath) -or -not (Test-Path -LiteralPath $specPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: spec-path is required and must exist.' } }
81
+ $specContent = Get-Content -LiteralPath $specPath -Raw -ErrorAction Stop
82
+ if (-not (Test-SpecNumericCriterion -SpecContent $specContent)) { return @{ Ok = $true; Message = $null } }
83
+ $researchPath = Get-ArtifactPathFromOutput -Output $output -Label 'research-path'
84
+ if ([string]::IsNullOrWhiteSpace($researchPath) -or -not (Test-Path -LiteralPath $researchPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: numeric acceptance criterion requires an existing research-path.' } }
85
+ return Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop)
86
+ }
87
+
88
+ if ($MyInvocation.InvocationName -eq '.') { return }
89
+ $result = Invoke-PrdFeatureOutputValidation -RawPayload $env:CLAUDE_HOOK_INPUT
90
+ if (-not $result.Ok) { Write-Error $result.Message; exit 1 }
91
+ exit 0